aboutsummaryrefslogtreecommitdiffstats
path: root/packages/renderer
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-03-30 21:47:45 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-05-16 00:54:57 +0200
commit85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9 (patch)
tree277ab45a66a1c74e2d0a885c8a354aea27128d12 /packages/renderer
parentfeat(main): Translation hot reloading during development (diff)
downloadsophie-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')
-rw-r--r--packages/renderer/package.json4
-rw-r--r--packages/renderer/src/components/Loading.tsx39
-rw-r--r--packages/renderer/src/components/NewWindowBanner.tsx25
-rw-r--r--packages/renderer/src/components/NotificationBanner.tsx4
-rw-r--r--packages/renderer/src/components/locationBar/ExtraButtons.tsx7
-rw-r--r--packages/renderer/src/components/locationBar/NavigationButtons.tsx14
-rw-r--r--packages/renderer/src/components/locationBar/SecurityLabel.tsx11
-rw-r--r--packages/renderer/src/components/sidebar/ServiceIcon.tsx4
-rw-r--r--packages/renderer/src/components/sidebar/ServiceSwitcher.tsx34
-rw-r--r--packages/renderer/src/components/sidebar/ToggleDarkModeButton.tsx4
-rw-r--r--packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx4
-rw-r--r--packages/renderer/src/i18n/RendererIpcI18nBackend.ts75
-rw-r--r--packages/renderer/src/i18n/loadRendererLoalization.ts87
-rw-r--r--packages/renderer/src/index.tsx18
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
21import CircularProgress from '@mui/material/CircularProgress';
22import { styled } from '@mui/material/styles';
23import React from 'react';
24
25const LoadingRoot = styled('div')({
26 width: '100vw',
27 height: '100vh',
28 display: 'flex',
29 alignItems: 'center',
30 justifyContent: 'center',
31});
32
33export 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';
23import Button from '@mui/material/Button'; 23import Button from '@mui/material/Button';
24import { observer } from 'mobx-react-lite'; 24import { observer } from 'mobx-react-lite';
25import React from 'react'; 25import React from 'react';
26import { Trans, useTranslation } from 'react-i18next';
26 27
27import type Service from '../stores/Service'; 28import 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';
23import Box from '@mui/material/Box'; 23import Box from '@mui/material/Box';
24import { styled } from '@mui/material/styles'; 24import { styled } from '@mui/material/styles';
25import React, { ReactNode } from 'react'; 25import React, { ReactNode } from 'react';
26import { useTranslation } from 'react-i18next';
26 27
27const NotificationBannerRoot = styled(Alert)(({ theme }) => ({ 28const 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';
23import IconButton from '@mui/material/IconButton'; 23import IconButton from '@mui/material/IconButton';
24import { observer } from 'mobx-react-lite'; 24import { observer } from 'mobx-react-lite';
25import React from 'react'; 25import React from 'react';
26import { useTranslation } from 'react-i18next';
26 27
27import type Service from '../../stores/Service'; 28import 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';
28import IconButton from '@mui/material/IconButton'; 28import IconButton from '@mui/material/IconButton';
29import { observer } from 'mobx-react-lite'; 29import { observer } from 'mobx-react-lite';
30import React from 'react'; 30import React from 'react';
31import { useTranslation } from 'react-i18next';
31 32
32import type Service from '../../stores/Service'; 33import 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';
24import IconWarning from '@mui/icons-material/Warning'; 24import IconWarning from '@mui/icons-material/Warning';
25import { styled } from '@mui/material/styles'; 25import { styled } from '@mui/material/styles';
26import React from 'react'; 26import React from 'react';
27import { useTranslation } from 'react-i18next';
27 28
28import LocationInputAdornment from './LocationInputAdornment'; 29import LocationInputAdornment from './LocationInputAdornment';
29import getAlertColor from './getAlertColor'; 30import 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 @@
21import Tab from '@mui/material/Tab'; 21import Tab from '@mui/material/Tab';
22import Tabs from '@mui/material/Tabs'; 22import Tabs from '@mui/material/Tabs';
23import { alpha, styled } from '@mui/material/styles'; 23import { alpha, styled } from '@mui/material/styles';
24import type { TFunction } from 'i18next';
24import { observer } from 'mobx-react-lite'; 25import { observer } from 'mobx-react-lite';
25import React from 'react'; 26import React from 'react';
27import { useTranslation } from 'react-i18next';
26 28
29import type Service from '../../stores/Service';
27import { useStore } from '../StoreProvider'; 30import { useStore } from '../StoreProvider';
28 31
29import ServiceIcon from './ServiceIcon'; 32import ServiceIcon from './ServiceIcon';
@@ -63,7 +66,36 @@ const ServiceSwitcherTab = styled(Tab, {
63 }, 66 },
64})); 67}));
65 68
69function 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
66function ServiceSwitcher(): JSX.Element { 97function 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';
23import IconButton from '@mui/material/IconButton'; 23import IconButton from '@mui/material/IconButton';
24import { observer } from 'mobx-react-lite'; 24import { observer } from 'mobx-react-lite';
25import React from 'react'; 25import React from 'react';
26import { useTranslation } from 'react-i18next';
26 27
27import { useStore } from '../StoreProvider'; 28import { useStore } from '../StoreProvider';
28 29
29export default observer(() => { 30export 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';
25import IconButton from '@mui/material/IconButton'; 25import IconButton from '@mui/material/IconButton';
26import { observer } from 'mobx-react-lite'; 26import { observer } from 'mobx-react-lite';
27import React from 'react'; 27import React from 'react';
28import { useTranslation } from 'react-i18next';
28 29
29import { useStore } from '../StoreProvider'; 30import { useStore } from '../StoreProvider';
30import { LOCATION_BAR_ID } from '../locationBar/LocationBar'; 31import { LOCATION_BAR_ID } from '../locationBar/LocationBar';
@@ -45,6 +46,7 @@ function ToggleLocationBarIcon({
45} 46}
46 47
47function ToggleLocationBarButton(): JSX.Element { 48function 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
21import { SophieRenderer } from '@sophie/shared';
22import type { BackendModule, ReadCallback } from 'i18next';
23
24export 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
21import { fallbackLng, SophieRenderer } from '@sophie/shared';
22import i18next from 'i18next';
23import { autorun } from 'mobx';
24import { addDisposer } from 'mobx-state-tree';
25import { initReactI18next } from 'react-i18next';
26
27import RendererStore from '../stores/RendererStore';
28import { getLogger } from '../utils/log';
29
30import RendererIpcI18nBackend from './RendererIpcI18nBackend';
31
32const log = getLogger('loadRendererLocalization');
33
34export 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';
24import '@fontsource/roboto/700.css'; 24import '@fontsource/roboto/700.css';
25import CssBaseline from '@mui/material/CssBaseline'; 25import CssBaseline from '@mui/material/CssBaseline';
26import { autorun } from 'mobx'; 26import { autorun } from 'mobx';
27import React from 'react'; 27import { addDisposer } from 'mobx-state-tree';
28import React, { Suspense } from 'react';
28import { render } from 'react-dom'; 29import { render } from 'react-dom';
29 30
30import App from './components/App'; 31import App from './components/App';
32import Loading from './components/Loading';
31import StoreProvider from './components/StoreProvider'; 33import StoreProvider from './components/StoreProvider';
32import ThemeProvider from './components/ThemeProvider'; 34import ThemeProvider from './components/ThemeProvider';
33import { exposeToReduxDevtools, hotReload } from './devTools'; 35import { exposeToReduxDevtools, hotReload } from './devTools';
36import loadRendererLocalization from './i18n/loadRendererLoalization';
34import { createAndConnectRendererStore } from './stores/RendererStore'; 37import { createAndConnectRendererStore } from './stores/RendererStore';
35import { getLogger } from './utils/log'; 38import { getLogger } from './utils/log';
36 39
@@ -42,7 +45,9 @@ if (isDevelopment) {
42 hotReload(); 45 hotReload();
43} 46}
44 47
45const store = createAndConnectRendererStore(window.sophieRenderer); 48const { sophieRenderer: ipc } = window;
49
50const store = createAndConnectRendererStore(ipc);
46 51
47if (isDevelopment) { 52if (isDevelopment) {
48 exposeToReduxDevtools(store).catch((error) => { 53 exposeToReduxDevtools(store).catch((error) => {
@@ -50,13 +55,16 @@ if (isDevelopment) {
50 }); 55 });
51} 56}
52 57
53autorun(() => { 58loadRendererLocalization(store, ipc, isDevelopment);
59
60const 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});
67addDisposer(store, disposeSetTitle);
60 68
61function Root(): JSX.Element { 69function 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>