aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--locales/en/translation.json38
-rw-r--r--packages/main/src/i18n/I18nStore.ts31
-rw-r--r--packages/main/src/i18n/loadLocalization.ts23
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts25
-rw-r--r--packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts5
-rw-r--r--packages/main/src/infrastructure/electron/types.ts2
-rw-r--r--packages/main/src/stores/MainStore.ts71
-rw-r--r--packages/preload/package.json1
-rw-r--r--packages/preload/src/contextBridge/SharedStoreConnector.ts88
-rw-r--r--packages/preload/src/contextBridge/TranslationsConnector.ts71
-rw-r--r--packages/preload/src/contextBridge/createSophieRenderer.ts88
-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
-rw-r--r--packages/shared/esbuild.config.js2
-rw-r--r--packages/shared/package.json1
-rw-r--r--packages/shared/src/contextBridge/SophieRenderer.ts7
-rw-r--r--packages/shared/src/index.ts4
-rw-r--r--packages/shared/src/ipc.ts2
-rw-r--r--packages/shared/src/schemas/Action.ts7
-rw-r--r--packages/shared/src/schemas/Translation.ts33
-rw-r--r--packages/shared/src/stores/SharedStoreBase.ts1
-rw-r--r--yarn.lock169
34 files changed, 806 insertions, 193 deletions
diff --git a/locales/en/translation.json b/locales/en/translation.json
index 2cd7959..e215adc 100644
--- a/locales/en/translation.json
+++ b/locales/en/translation.json
@@ -1,4 +1,16 @@
1{ 1{
2 "banner": {
3 "close": "Dismiss notification",
4 "newWindow": {
5 "followLink": "Follow link in this window",
6 "openInExternalBrowser": "Open in browser",
7 "openAllInExternalBrowser": "Open all",
8 "dismiss": "Ignore",
9 "messageSingleLink": "{{name}} wants to open <2>{{url}}</2> in a new window",
10 "messageMultipleLinks_one": "{{name}} wants to open <2>{{url}}</2> and <5>{{count}}</5> other link in new windows",
11 "messageMultipleLinks_other": "{{name}} wants to open <2>{{url}}</2> and <5>{{count}}</5> other links in new windows"
12 }
13 },
2 "menu": { 14 "menu": {
3 "servicesMenu": "Services", 15 "servicesMenu": "Services",
4 "view": { 16 "view": {
@@ -16,5 +28,31 @@
16 "help": { 28 "help": {
17 "gitlab": "Gitlab" 29 "gitlab": "Gitlab"
18 } 30 }
31 },
32 "securityLabel": {
33 "notSecureConnection": "Not secure",
34 "secureConnection": "Secure connection",
35 "unknownSite": "Unknown site"
36 },
37 "service": {
38 "title": {
39 "nameWithMessages": "{{name}} ({{messages}})",
40 "nameWithNoMessages": "{{name}}",
41 "directMessageCount_one": "{{count}} direct message",
42 "directMessageCount_other": "{{count}} direct messages",
43 "indirectMessageCount_one": "{{count}} indirect message",
44 "indirectMessageCount_other": "{{count}} indirect messages",
45 "directAndIndirectMessageCount": "$t(service.title.directMessageCount, { \"count\": {{directMessageCount}} }) and $t(service.title.indirectMessageCount, { \"count\": {{indirectMessageCount}} })"
46 }
47 },
48 "toolbar": {
49 "back": "Back",
50 "forward": "Forward",
51 "home": "Home",
52 "openInBrowser": "Open in browser",
53 "reload": "Reload",
54 "stop": "Stop",
55 "toggleDarkMode": "Toggle dark mode",
56 "toggleLocationBar": "Toggle location bar"
19 } 57 }
20} 58}
diff --git a/packages/main/src/i18n/I18nStore.ts b/packages/main/src/i18n/I18nStore.ts
index c364f0e..54c3d20 100644
--- a/packages/main/src/i18n/I18nStore.ts
+++ b/packages/main/src/i18n/I18nStore.ts
@@ -18,7 +18,7 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { i18n, TFunction } from 'i18next'; 21import type { i18n, ResourceKey, TFunction } from 'i18next';
22import { IAtom, createAtom } from 'mobx'; 22import { IAtom, createAtom } from 'mobx';
23 23
24import { getLogger } from '../utils/log'; 24import { getLogger } from '../utils/log';
@@ -113,4 +113,33 @@ export default class I18nStore {
113 }); 113 });
114 log.debug('Reloaded translations'); 114 log.debug('Reloaded translations');
115 } 115 }
116
117 async getTranslation(
118 language: string,
119 namespace: string,
120 ): Promise<ResourceKey> {
121 if (!this.i18next.hasResourceBundle(language, namespace)) {
122 await this.i18next.loadLanguages([language]);
123 await this.i18next.loadNamespaces([namespace]);
124 }
125 const bundle = this.i18next.getResourceBundle(
126 language,
127 namespace,
128 ) as unknown;
129 if (typeof bundle !== 'object' || bundle === null) {
130 throw new Error(
131 `Failed to load ${namespace} resource bundle for language ${language}`,
132 );
133 }
134 return bundle as ResourceKey;
135 }
136
137 addMissingTranslation(
138 languages: string[],
139 namespace: string,
140 key: string,
141 value: string,
142 ): void {
143 this.i18next.modules.backend?.create?.(languages, namespace, key, value);
144 }
116} 145}
diff --git a/packages/main/src/i18n/loadLocalization.ts b/packages/main/src/i18n/loadLocalization.ts
index 1408a30..ec3cf84 100644
--- a/packages/main/src/i18n/loadLocalization.ts
+++ b/packages/main/src/i18n/loadLocalization.ts
@@ -18,16 +18,22 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { fallbackLng } from '@sophie/shared';
21import i18next from 'i18next'; 22import i18next from 'i18next';
23import { autorun } from 'mobx';
24import { addDisposer } from 'mobx-state-tree';
22 25
23import type Resources from '../infrastructure/resources/Resources'; 26import type Resources from '../infrastructure/resources/Resources';
24import type MainStore from '../stores/MainStore'; 27import type MainStore from '../stores/MainStore';
28import { getLogger } from '../utils/log';
25 29
26import I18nStore from './I18nStore'; 30import I18nStore from './I18nStore';
27import RepositoryBasedI18nBackend from './RepositoryBasedI18nBackend'; 31import RepositoryBasedI18nBackend from './RepositoryBasedI18nBackend';
28import i18nLog from './i18nLog'; 32import i18nLog from './i18nLog';
29import LocalizationFiles from './impl/LocaltizationFiles'; 33import LocalizationFiles from './impl/LocaltizationFiles';
30 34
35const log = getLogger('loadLocationzation');
36
31export default async function loadLocalization( 37export default async function loadLocalization(
32 store: MainStore, 38 store: MainStore,
33 resources: Resources, 39 resources: Resources,
@@ -37,14 +43,25 @@ export default async function loadLocalization(
37 const backend = new RepositoryBasedI18nBackend(repository, devMode); 43 const backend = new RepositoryBasedI18nBackend(repository, devMode);
38 const i18n = i18next 44 const i18n = i18next
39 .createInstance({ 45 .createInstance({
40 lng: 'en', 46 lng: store.shared.language,
41 fallbackLng: ['en'], 47 fallbackLng,
42 debug: devMode, 48 debug: devMode,
43 saveMissing: devMode, 49 saveMissing: devMode,
44 }) 50 })
45 .use(backend) 51 .use(backend)
46 .use(i18nLog); 52 .use(i18nLog);
47 await i18n.init();
48 const i18nStore = new I18nStore(i18n); 53 const i18nStore = new I18nStore(i18n);
49 store.setI18n(i18nStore); 54 store.setI18n(i18nStore);
55 await i18n.init();
56 const disposeChangeLanguage = autorun(() => {
57 const {
58 shared: { language },
59 } = store;
60 if (i18n.language !== language) {
61 i18n.changeLanguage(language).catch((error) => {
62 log.error('Failed to change language', error);
63 });
64 }
65 });
66 addDisposer(store, disposeChangeLanguage);
50} 67}
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts b/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts
index cff7957..6144d89 100644
--- a/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts
+++ b/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts
@@ -22,6 +22,7 @@ import {
22 Action, 22 Action,
23 MainToRendererIpcMessage, 23 MainToRendererIpcMessage,
24 RendererToMainIpcMessage, 24 RendererToMainIpcMessage,
25 Translation,
25} from '@sophie/shared'; 26} from '@sophie/shared';
26import { BrowserWindow, ipcMain, IpcMainEvent } from 'electron'; 27import { BrowserWindow, ipcMain, IpcMainEvent } from 'electron';
27import type { IJsonPatch } from 'mobx-state-tree'; 28import type { IJsonPatch } from 'mobx-state-tree';
@@ -96,6 +97,24 @@ export default class ElectronMainWindow implements MainWindow {
96 return this.bridge.snapshot; 97 return this.bridge.snapshot;
97 }); 98 });
98 99
100 ipcMain.handle(
101 RendererToMainIpcMessage.GetTranslation,
102 (event, translation) => {
103 const { id } = event.sender;
104 if (id !== webContents.id) {
105 log.warn(
106 'Unexpected',
107 RendererToMainIpcMessage.GetTranslation,
108 'from webContents',
109 id,
110 );
111 throw new Error('Invalid IPC call');
112 }
113 const { language, namespace } = Translation.parse(translation);
114 return store.getTranslation(language, namespace);
115 },
116 );
117
99 this.bridge = new RendererBridge(store, (patch) => { 118 this.bridge = new RendererBridge(store, (patch) => {
100 webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch); 119 webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch);
101 }); 120 });
@@ -142,6 +161,12 @@ export default class ElectronMainWindow implements MainWindow {
142 ); 161 );
143 } 162 }
144 163
164 reloadTranslations(): void {
165 this.browserWindow.webContents.send(
166 MainToRendererIpcMessage.ReloadTranslations,
167 );
168 }
169
145 dispose() { 170 dispose() {
146 this.bridge.dispose(); 171 this.bridge.dispose();
147 this.browserWindow.destroy(); 172 this.browserWindow.destroy();
diff --git a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts
index 49bfbfd..8e10383 100644
--- a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts
+++ b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts
@@ -91,10 +91,7 @@ export default function setApplicationMenu(
91 }, 91 },
92 { 92 {
93 role: 'toggleDevTools', 93 role: 'toggleDevTools',
94 label: t<string>( 94 label: t<string>('menu.view.toggleSophieDeveloperTools'),
95 'menu.view.toggleSophieDeveloperTools',
96 'Toggle Sophie Developer Tools',
97 ),
98 accelerator: 'CommandOrControl+Shift+Alt+I', 95 accelerator: 'CommandOrControl+Shift+Alt+I',
99 }, 96 },
100 { type: 'separator' }, 97 { type: 'separator' },
diff --git a/packages/main/src/infrastructure/electron/types.ts b/packages/main/src/infrastructure/electron/types.ts
index 4716f0b..e5b0fd6 100644
--- a/packages/main/src/infrastructure/electron/types.ts
+++ b/packages/main/src/infrastructure/electron/types.ts
@@ -41,6 +41,8 @@ export interface MainWindow {
41 41
42 setServiceView(serviceView: ServiceView | undefined): void; 42 setServiceView(serviceView: ServiceView | undefined): void;
43 43
44 reloadTranslations(): void;
45
44 dispose(): void; 46 dispose(): void;
45} 47}
46 48
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts
index 93cabb9..d717bed 100644
--- a/packages/main/src/stores/MainStore.ts
+++ b/packages/main/src/stores/MainStore.ts
@@ -19,7 +19,8 @@
19 */ 19 */
20 20
21import type { Action, BrowserViewBounds } from '@sophie/shared'; 21import type { Action, BrowserViewBounds } from '@sophie/shared';
22import { applySnapshot, Instance, types } from 'mobx-state-tree'; 22import type { ResourceKey } from 'i18next';
23import { applySnapshot, flow, Instance, types } from 'mobx-state-tree';
23 24
24import type I18nStore from '../i18n/I18nStore'; 25import type I18nStore from '../i18n/I18nStore';
25import type { UseTranslationResult } from '../i18n/I18nStore'; 26import type { UseTranslationResult } from '../i18n/I18nStore';
@@ -67,6 +68,12 @@ const MainStore = types
67 useTranslation(ns?: string): UseTranslationResult { 68 useTranslation(ns?: string): UseTranslationResult {
68 return self.i18n?.useTranslation(ns) ?? { ready: false }; 69 return self.i18n?.useTranslation(ns) ?? { ready: false };
69 }, 70 },
71 getTranslation(language: string, namespace: string): Promise<ResourceKey> {
72 if (self.i18n === undefined) {
73 throw new Error('i18next has not been set');
74 }
75 return self.i18n.getTranslation(language, namespace);
76 },
70 })) 77 }))
71 .volatile( 78 .volatile(
72 (): { 79 (): {
@@ -79,10 +86,43 @@ const MainStore = types
79 setBrowserViewBounds(bounds: BrowserViewBounds): void { 86 setBrowserViewBounds(bounds: BrowserViewBounds): void {
80 applySnapshot(self.browserViewBounds, bounds); 87 applySnapshot(self.browserViewBounds, bounds);
81 }, 88 },
89 setMainWindow(mainWindow: MainWindow | undefined): void {
90 self.mainWindow = mainWindow;
91 },
92 openWebpageInBrowser() {
93 getEnv(self).openURLInExternalBrowser(
94 'https://gitlab.com/say-hi-to-sophie/shophie',
95 );
96 },
97 openAboutDialog() {
98 getEnv(self).openAboutDialog();
99 },
100 beforeDestroy(): void {
101 self.mainWindow?.dispose();
102 },
103 setI18n(i18n: I18nStore): void {
104 self.i18n = i18n;
105 },
106 addMissingTranslation(
107 languages: string[],
108 namespace: string,
109 key: string,
110 value: string,
111 ): void {
112 self.i18n?.addMissingTranslation(languages, namespace, key, value);
113 },
114 reloadTranslations: flow(function* reloadTranslations() {
115 if (self.i18n !== undefined) {
116 yield self.i18n.reloadTranslations();
117 self.mainWindow?.reloadTranslations();
118 }
119 }),
120 }))
121 .actions((self) => ({
82 dispatch(action: Action): void { 122 dispatch(action: Action): void {
83 switch (action.action) { 123 switch (action.action) {
84 case 'set-browser-view-bounds': 124 case 'set-browser-view-bounds':
85 this.setBrowserViewBounds(action.browserViewBounds); 125 self.setBrowserViewBounds(action.browserViewBounds);
86 break; 126 break;
87 case 'set-selected-service-id': 127 case 'set-selected-service-id':
88 self.settings.setSelectedServiceId(action.serviceId); 128 self.settings.setSelectedServiceId(action.serviceId);
@@ -98,11 +138,19 @@ const MainStore = types
98 break; 138 break;
99 case 'reload-all-translations': 139 case 'reload-all-translations':
100 if (self.i18n !== undefined) { 140 if (self.i18n !== undefined) {
101 self.i18n.reloadTranslations().catch((error) => { 141 self.reloadTranslations().catch((error) => {
102 log.error('Failed to reload translations', error); 142 log.error('Failed to reload translations', error);
103 }); 143 });
104 } 144 }
105 break; 145 break;
146 case 'add-missing-translation':
147 self.addMissingTranslation(
148 action.languages,
149 action.namespace,
150 action.key,
151 action.value,
152 );
153 break;
106 case 'dispatch-service-action': { 154 case 'dispatch-service-action': {
107 const { serviceId, serviceAction } = action; 155 const { serviceId, serviceAction } = action;
108 const service = self.shared.servicesById.get(serviceId); 156 const service = self.shared.servicesById.get(serviceId);
@@ -123,23 +171,6 @@ const MainStore = types
123 break; 171 break;
124 } 172 }
125 }, 173 },
126 setMainWindow(mainWindow: MainWindow | undefined): void {
127 self.mainWindow = mainWindow;
128 },
129 openWebpageInBrowser() {
130 getEnv(self).openURLInExternalBrowser(
131 'https://gitlab.com/say-hi-to-sophie/shophie',
132 );
133 },
134 openAboutDialog() {
135 getEnv(self).openAboutDialog();
136 },
137 beforeDestroy(): void {
138 self.mainWindow?.dispose();
139 },
140 setI18n(i18n: I18nStore): void {
141 self.i18n = i18n;
142 },
143 })); 174 }));
144 175
145/* 176/*
diff --git a/packages/preload/package.json b/packages/preload/package.json
index 150d514..a8d73e8 100644
--- a/packages/preload/package.json
+++ b/packages/preload/package.json
@@ -11,6 +11,7 @@
11 "dependencies": { 11 "dependencies": {
12 "@sophie/shared": "workspace:*", 12 "@sophie/shared": "workspace:*",
13 "electron": "17.1.0", 13 "electron": "17.1.0",
14 "i18next": "^21.6.14",
14 "loglevel": "^1.8.0", 15 "loglevel": "^1.8.0",
15 "mobx": "^6.4.1", 16 "mobx": "^6.4.1",
16 "mobx-state-tree": "^5.1.3" 17 "mobx-state-tree": "^5.1.3"
diff --git a/packages/preload/src/contextBridge/SharedStoreConnector.ts b/packages/preload/src/contextBridge/SharedStoreConnector.ts
new file mode 100644
index 0000000..13f9ac1
--- /dev/null
+++ b/packages/preload/src/contextBridge/SharedStoreConnector.ts
@@ -0,0 +1,88 @@
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 {
22 Action,
23 MainToRendererIpcMessage,
24 RendererToMainIpcMessage,
25 SharedStoreListener,
26 SharedStoreSnapshotIn,
27} from '@sophie/shared';
28import { ipcRenderer } from 'electron';
29import log from 'loglevel';
30import type { IJsonPatch } from 'mobx-state-tree';
31
32export default class SharedStoreConnector {
33 private onSharedStoreChangeCalled = false;
34
35 private listener: SharedStoreListener | undefined;
36
37 constructor(private readonly allowReplaceListener: boolean) {
38 ipcRenderer.on(
39 MainToRendererIpcMessage.SharedStorePatch,
40 (_event, patch) => {
41 try {
42 // `mobx-state-tree` will validate the patch, so we can safely cast here.
43 this.listener?.onPatch(patch as IJsonPatch[]);
44 } catch (error) {
45 log.error('Shared store listener onPatch failed', error);
46 this.listener = undefined;
47 }
48 },
49 );
50 }
51
52 async onSharedStoreChange(listener: SharedStoreListener): Promise<void> {
53 if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) {
54 throw new Error('Shared store change listener was already set');
55 }
56 this.onSharedStoreChangeCalled = true;
57 let success = false;
58 let snapshot: unknown;
59 try {
60 snapshot = await ipcRenderer.invoke(
61 RendererToMainIpcMessage.GetSharedStoreSnapshot,
62 );
63 success = true;
64 } catch (error) {
65 log.error('Failed to get initial shared store snapshot', error);
66 }
67 if (!success) {
68 throw new Error('Failed to connect to shared store');
69 }
70 // `mobx-state-tree` will validate the snapshot, so we can safely cast here.
71 listener.onSnapshot(snapshot as SharedStoreSnapshotIn);
72 this.listener = listener;
73 }
74}
75
76export function dispatchAction(actionToDispatch: Action): void {
77 // Let the full zod parse error bubble up to the main world,
78 // since all data it may contain was provided from the main world.
79 const parsedAction = Action.parse(actionToDispatch);
80 try {
81 ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction);
82 } catch (error) {
83 // Do not leak IPC failure details into the main world.
84 const message = 'Failed to dispatch action';
85 log.error(message, actionToDispatch, error);
86 throw new Error(message);
87 }
88}
diff --git a/packages/preload/src/contextBridge/TranslationsConnector.ts b/packages/preload/src/contextBridge/TranslationsConnector.ts
new file mode 100644
index 0000000..284b793
--- /dev/null
+++ b/packages/preload/src/contextBridge/TranslationsConnector.ts
@@ -0,0 +1,71 @@
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 {
22 MainToRendererIpcMessage,
23 RendererToMainIpcMessage,
24 Translation,
25} from '@sophie/shared';
26import { ipcRenderer } from 'electron';
27import type { ResourceKey } from 'i18next';
28import log from 'loglevel';
29
30export default class TranslationsConnector {
31 private listener: (() => void) | undefined;
32
33 constructor(private readonly devMode: boolean) {
34 ipcRenderer.on(MainToRendererIpcMessage.ReloadTranslations, () => {
35 try {
36 this.listener?.();
37 } catch (error) {
38 log.error('Translations listener onReloadTranslations failed', error);
39 this.listener = undefined;
40 }
41 });
42 }
43
44 onReloadTranslations(listener: () => void): void {
45 if (!this.devMode) {
46 throw new Error(
47 'Translation reloading is only supported in development mode',
48 );
49 }
50 this.listener = listener;
51 }
52}
53
54export async function getTranslation(
55 translation: Translation,
56): Promise<ResourceKey> {
57 const parsedTranslation = Translation.parse(translation);
58 try {
59 // We don't have any way to validate translations,
60 // but they are coming from a trusted source and will be validated
61 // in the renderer anyways, so we should be fine.
62 return (await ipcRenderer.invoke(
63 RendererToMainIpcMessage.GetTranslation,
64 parsedTranslation,
65 )) as ResourceKey;
66 } catch (error) {
67 const message = 'Failed to get translation';
68 log.error(message, translation, error);
69 throw new Error(message);
70 }
71}
diff --git a/packages/preload/src/contextBridge/createSophieRenderer.ts b/packages/preload/src/contextBridge/createSophieRenderer.ts
index 8bdf07e..3de9d9e 100644
--- a/packages/preload/src/contextBridge/createSophieRenderer.ts
+++ b/packages/preload/src/contextBridge/createSophieRenderer.ts
@@ -18,85 +18,21 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { 21import { SophieRenderer } from '@sophie/shared';
22 Action,
23 MainToRendererIpcMessage,
24 RendererToMainIpcMessage,
25 SharedStoreListener,
26 SharedStoreSnapshotIn,
27 SophieRenderer,
28} from '@sophie/shared';
29import { ipcRenderer } from 'electron';
30import log from 'loglevel';
31import type { IJsonPatch } from 'mobx-state-tree';
32 22
33class SharedStoreConnector { 23import SharedStoreConnector, { dispatchAction } from './SharedStoreConnector';
34 readonly #allowReplaceListener: boolean; 24import TranslationsConnector, { getTranslation } from './TranslationsConnector';
35 25
36 #onSharedStoreChangeCalled = false; 26export default function createSophieRenderer(devMode: boolean): SophieRenderer {
37 27 const sharedStoreConnector = new SharedStoreConnector(devMode);
38 #listener: SharedStoreListener | undefined; 28 const translationsConnector = new TranslationsConnector(devMode);
39
40 constructor(allowReplaceListener: boolean) {
41 this.#allowReplaceListener = allowReplaceListener;
42 ipcRenderer.on(
43 MainToRendererIpcMessage.SharedStorePatch,
44 (_event, patch) => {
45 try {
46 // `mobx-state-tree` will validate the patch, so we can safely cast here.
47 this.#listener?.onPatch(patch as IJsonPatch[]);
48 } catch (error) {
49 log.error('Shared store listener onPatch failed', error);
50 this.#listener = undefined;
51 }
52 },
53 );
54 }
55
56 async onSharedStoreChange(listener: SharedStoreListener): Promise<void> {
57 if (this.#onSharedStoreChangeCalled && !this.#allowReplaceListener) {
58 throw new Error('Shared store change listener was already set');
59 }
60 this.#onSharedStoreChangeCalled = true;
61 let success = false;
62 let snapshot: unknown;
63 try {
64 snapshot = await ipcRenderer.invoke(
65 RendererToMainIpcMessage.GetSharedStoreSnapshot,
66 );
67 success = true;
68 } catch (error) {
69 log.error('Failed to get initial shared store snapshot', error);
70 }
71 if (!success) {
72 throw new Error('Failed to connect to shared store');
73 }
74 // `mobx-state-tree` will validate the snapshot, so we can safely cast here.
75 listener.onSnapshot(snapshot as SharedStoreSnapshotIn);
76 this.#listener = listener;
77 }
78}
79
80function dispatchAction(actionToDispatch: Action): void {
81 // Let the full zod parse error bubble up to the main world,
82 // since all data it may contain was provided from the main world.
83 const parsedAction = Action.parse(actionToDispatch);
84 try {
85 ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction);
86 } catch (error) {
87 // Do not leak IPC failure details into the main world.
88 const message = 'Failed to dispatch action';
89 log.error(message, actionToDispatch, error);
90 throw new Error(message);
91 }
92}
93
94export default function createSophieRenderer(
95 allowReplaceListener: boolean,
96): SophieRenderer {
97 const connector = new SharedStoreConnector(allowReplaceListener);
98 return { 29 return {
99 onSharedStoreChange: connector.onSharedStoreChange.bind(connector), 30 onSharedStoreChange:
31 sharedStoreConnector.onSharedStoreChange.bind(sharedStoreConnector),
100 dispatchAction, 32 dispatchAction,
33 getTranslation,
34 onReloadTranslations: translationsConnector.onReloadTranslations.bind(
35 translationsConnector,
36 ),
101 }; 37 };
102} 38}
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>
diff --git a/packages/shared/esbuild.config.js b/packages/shared/esbuild.config.js
index e8dcc2f..6e46742 100644
--- a/packages/shared/esbuild.config.js
+++ b/packages/shared/esbuild.config.js
@@ -14,5 +14,5 @@ export default getEsbuildConfig({
14 minify: false, 14 minify: false,
15 platform: 'node', 15 platform: 'node',
16 target: [chrome, node], 16 target: [chrome, node],
17 external: ['mobx', 'mobx-state-tree', 'zod'], 17 external: ['i18next', 'mobx', 'mobx-state-tree', 'zod'],
18}); 18});
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 5b075e3..d39d7ee 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -11,6 +11,7 @@
11 "types": "yarn g:types" 11 "types": "yarn g:types"
12 }, 12 },
13 "dependencies": { 13 "dependencies": {
14 "i18next": "^21.6.14",
14 "mobx": "^6.4.1", 15 "mobx": "^6.4.1",
15 "mobx-state-tree": "^5.1.3", 16 "mobx-state-tree": "^5.1.3",
16 "zod": "^3.12.0" 17 "zod": "^3.12.0"
diff --git a/packages/shared/src/contextBridge/SophieRenderer.ts b/packages/shared/src/contextBridge/SophieRenderer.ts
index dc77c97..732f941 100644
--- a/packages/shared/src/contextBridge/SophieRenderer.ts
+++ b/packages/shared/src/contextBridge/SophieRenderer.ts
@@ -18,11 +18,18 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { ResourceKey } from 'i18next';
22
21import { Action } from '../schemas/Action'; 23import { Action } from '../schemas/Action';
24import { Translation } from '../schemas/Translation';
22import { SharedStoreListener } from '../stores/SharedStoreBase'; 25import { SharedStoreListener } from '../stores/SharedStoreBase';
23 26
24export default interface SophieRenderer { 27export default interface SophieRenderer {
25 onSharedStoreChange(this: void, listener: SharedStoreListener): Promise<void>; 28 onSharedStoreChange(this: void, listener: SharedStoreListener): Promise<void>;
26 29
27 dispatchAction(this: void, action: Action): void; 30 dispatchAction(this: void, action: Action): void;
31
32 getTranslation(this: void, translation: Translation): Promise<ResourceKey>;
33
34 onReloadTranslations(this: void, listener: () => void): void;
28} 35}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index f7c5bcf..51f9f06 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -18,6 +18,8 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21export const fallbackLng = ['en'];
22
21export type { default as SophieRenderer } from './contextBridge/SophieRenderer'; 23export type { default as SophieRenderer } from './contextBridge/SophieRenderer';
22 24
23export { MainToRendererIpcMessage, RendererToMainIpcMessage } from './ipc'; 25export { MainToRendererIpcMessage, RendererToMainIpcMessage } from './ipc';
@@ -30,6 +32,8 @@ export { ServiceAction } from './schemas/ServiceAction';
30 32
31export { ThemeSource } from './schemas/ThemeSource'; 33export { ThemeSource } from './schemas/ThemeSource';
32 34
35export { Translation } from './schemas/Translation';
36
33export type { CertificateSnapshotIn } from './stores/Certificate'; 37export type { CertificateSnapshotIn } from './stores/Certificate';
34export { default as Certificate } from './stores/Certificate'; 38export { default as Certificate } from './stores/Certificate';
35 39
diff --git a/packages/shared/src/ipc.ts b/packages/shared/src/ipc.ts
index 54d761a..2716633 100644
--- a/packages/shared/src/ipc.ts
+++ b/packages/shared/src/ipc.ts
@@ -20,9 +20,11 @@
20 20
21export enum MainToRendererIpcMessage { 21export enum MainToRendererIpcMessage {
22 SharedStorePatch = 'sophie-main-to-renderer:shared-store-patch', 22 SharedStorePatch = 'sophie-main-to-renderer:shared-store-patch',
23 ReloadTranslations = 'sophie-main-to-renderer:reload-translations',
23} 24}
24 25
25export enum RendererToMainIpcMessage { 26export enum RendererToMainIpcMessage {
26 GetSharedStoreSnapshot = 'sophie-renderer-to-main:get-shared-store-snapshot', 27 GetSharedStoreSnapshot = 'sophie-renderer-to-main:get-shared-store-snapshot',
28 GetTranslation = 'sophie-renderer-to-main:get-translation',
27 DispatchAction = 'sophie-renderer-to-main:dispatch-action', 29 DispatchAction = 'sophie-renderer-to-main:dispatch-action',
28} 30}
diff --git a/packages/shared/src/schemas/Action.ts b/packages/shared/src/schemas/Action.ts
index 8d6ca3a..ce983fa 100644
--- a/packages/shared/src/schemas/Action.ts
+++ b/packages/shared/src/schemas/Action.ts
@@ -49,6 +49,13 @@ export const Action = /* @__PURE__ */ (() =>
49 action: z.literal('reload-all-translations'), 49 action: z.literal('reload-all-translations'),
50 }), 50 }),
51 z.object({ 51 z.object({
52 action: z.literal('add-missing-translation'),
53 languages: z.string().nonempty().array(),
54 namespace: z.string().nonempty(),
55 key: z.string().nonempty(),
56 value: z.string(),
57 }),
58 z.object({
52 action: z.literal('dispatch-service-action'), 59 action: z.literal('dispatch-service-action'),
53 serviceId: z.string(), 60 serviceId: z.string(),
54 serviceAction: ServiceAction, 61 serviceAction: ServiceAction,
diff --git a/packages/shared/src/schemas/Translation.ts b/packages/shared/src/schemas/Translation.ts
new file mode 100644
index 0000000..ab513b5
--- /dev/null
+++ b/packages/shared/src/schemas/Translation.ts
@@ -0,0 +1,33 @@
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 { z } from 'zod';
22
23export const Translation = /* @__PURE__ */ (() =>
24 z.object({
25 language: z.string().nonempty(),
26 namespace: z.string().nonempty(),
27 }))();
28
29/*
30 eslint-disable-next-line @typescript-eslint/no-redeclare --
31 Intentionally naming the type the same as the schema definition.
32*/
33export type Translation = z.infer<typeof Translation>;
diff --git a/packages/shared/src/stores/SharedStoreBase.ts b/packages/shared/src/stores/SharedStoreBase.ts
index 8d6624b..86bd0fc 100644
--- a/packages/shared/src/stores/SharedStoreBase.ts
+++ b/packages/shared/src/stores/SharedStoreBase.ts
@@ -43,6 +43,7 @@ export function defineSharedStoreModel<
43 servicesById: types.map(service), 43 servicesById: types.map(service),
44 services: types.array(types.reference(service)), 44 services: types.array(types.reference(service)),
45 shouldUseDarkColors: false, 45 shouldUseDarkColors: false,
46 language: 'en',
46 }); 47 });
47} 48}
48 49
diff --git a/yarn.lock b/yarn.lock
index b0afd2d..d1754ec 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -439,7 +439,7 @@ __metadata:
439 languageName: node 439 languageName: node
440 linkType: hard 440 linkType: hard
441 441
442"@babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.0, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.7": 442"@babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.7":
443 version: 7.17.2 443 version: 7.17.2
444 resolution: "@babel/runtime@npm:7.17.2" 444 resolution: "@babel/runtime@npm:7.17.2"
445 dependencies: 445 dependencies:
@@ -448,7 +448,7 @@ __metadata:
448 languageName: node 448 languageName: node
449 linkType: hard 449 linkType: hard
450 450
451"@babel/runtime@npm:^7.17.2": 451"@babel/runtime@npm:^7.14.5, @babel/runtime@npm:^7.17.2":
452 version: 7.17.8 452 version: 7.17.8
453 resolution: "@babel/runtime@npm:7.17.8" 453 resolution: "@babel/runtime@npm:7.17.8"
454 dependencies: 454 dependencies:
@@ -603,7 +603,7 @@ __metadata:
603 languageName: node 603 languageName: node
604 linkType: hard 604 linkType: hard
605 605
606"@emotion/is-prop-valid@npm:^1.1.1, @emotion/is-prop-valid@npm:^1.1.2": 606"@emotion/is-prop-valid@npm:^1.1.2":
607 version: 1.1.2 607 version: 1.1.2
608 resolution: "@emotion/is-prop-valid@npm:1.1.2" 608 resolution: "@emotion/is-prop-valid@npm:1.1.2"
609 dependencies: 609 dependencies:
@@ -1021,14 +1021,15 @@ __metadata:
1021 languageName: node 1021 languageName: node
1022 linkType: hard 1022 linkType: hard
1023 1023
1024"@mui/base@npm:5.0.0-alpha.69": 1024"@mui/base@npm:5.0.0-alpha.74":
1025 version: 5.0.0-alpha.69 1025 version: 5.0.0-alpha.74
1026 resolution: "@mui/base@npm:5.0.0-alpha.69" 1026 resolution: "@mui/base@npm:5.0.0-alpha.74"
1027 dependencies: 1027 dependencies:
1028 "@babel/runtime": ^7.17.0 1028 "@babel/runtime": ^7.17.2
1029 "@emotion/is-prop-valid": ^1.1.1 1029 "@emotion/is-prop-valid": ^1.1.2
1030 "@mui/utils": ^5.4.2 1030 "@mui/types": ^7.1.3
1031 "@popperjs/core": ^2.4.4 1031 "@mui/utils": ^5.5.3
1032 "@popperjs/core": ^2.11.4
1032 clsx: ^1.1.1 1033 clsx: ^1.1.1
1033 prop-types: ^15.7.2 1034 prop-types: ^15.7.2
1034 react-is: ^17.0.2 1035 react-is: ^17.0.2
@@ -1039,15 +1040,15 @@ __metadata:
1039 peerDependenciesMeta: 1040 peerDependenciesMeta:
1040 "@types/react": 1041 "@types/react":
1041 optional: true 1042 optional: true
1042 checksum: 8d5acc6abed1bba7a50252c65751258145544a87f3154c39fe46c8413f4613bac3d3bbd69fe3d5b57b370f3b0e1b8dd7d915e42fa622c6c22ef58487d55317d4 1043 checksum: e66a77933274eff27011bbe5a634c5812efdc0c7b96d1cf00cf7406080459919631fd929c0652ed18c5169d21ea7f3cac041b0f5a7473a984a915dacf9afe751
1043 languageName: node 1044 languageName: node
1044 linkType: hard 1045 linkType: hard
1045 1046
1046"@mui/icons-material@npm:^5.4.2": 1047"@mui/icons-material@npm:^5.4.2":
1047 version: 5.4.2 1048 version: 5.5.1
1048 resolution: "@mui/icons-material@npm:5.4.2" 1049 resolution: "@mui/icons-material@npm:5.5.1"
1049 dependencies: 1050 dependencies:
1050 "@babel/runtime": ^7.17.0 1051 "@babel/runtime": ^7.17.2
1051 peerDependencies: 1052 peerDependencies:
1052 "@mui/material": ^5.0.0 1053 "@mui/material": ^5.0.0
1053 "@types/react": ^16.8.6 || ^17.0.0 1054 "@types/react": ^16.8.6 || ^17.0.0
@@ -1055,22 +1056,22 @@ __metadata:
1055 peerDependenciesMeta: 1056 peerDependenciesMeta:
1056 "@types/react": 1057 "@types/react":
1057 optional: true 1058 optional: true
1058 checksum: 7db675c77bb25a18b48166491a4ec421281cc93b083467715139458e25ceaa5c7d4384cac8627977065704963e1aeda5428821a059db006020c150f2f69f8cf3 1059 checksum: 9b3430d99607ad243632cdc91c51a5115df35cf6c235165fe1ba5c1245e64ca6da107717e26d2e9f9b54f63c37663a90b74b54b906ab877dbd8bd8548398db42
1059 languageName: node 1060 languageName: node
1060 linkType: hard 1061 linkType: hard
1061 1062
1062"@mui/material@npm:^5.4.3": 1063"@mui/material@npm:^5.4.3":
1063 version: 5.4.3 1064 version: 5.5.3
1064 resolution: "@mui/material@npm:5.4.3" 1065 resolution: "@mui/material@npm:5.5.3"
1065 dependencies: 1066 dependencies:
1066 "@babel/runtime": ^7.17.0 1067 "@babel/runtime": ^7.17.2
1067 "@mui/base": 5.0.0-alpha.69 1068 "@mui/base": 5.0.0-alpha.74
1068 "@mui/system": ^5.4.3 1069 "@mui/system": ^5.5.3
1069 "@mui/types": ^7.1.2 1070 "@mui/types": ^7.1.3
1070 "@mui/utils": ^5.4.2 1071 "@mui/utils": ^5.5.3
1071 "@types/react-transition-group": ^4.4.4 1072 "@types/react-transition-group": ^4.4.4
1072 clsx: ^1.1.1 1073 clsx: ^1.1.1
1073 csstype: ^3.0.10 1074 csstype: ^3.0.11
1074 hoist-non-react-statics: ^3.3.2 1075 hoist-non-react-statics: ^3.3.2
1075 prop-types: ^15.7.2 1076 prop-types: ^15.7.2
1076 react-is: ^17.0.2 1077 react-is: ^17.0.2
@@ -1088,16 +1089,16 @@ __metadata:
1088 optional: true 1089 optional: true
1089 "@types/react": 1090 "@types/react":
1090 optional: true 1091 optional: true
1091 checksum: 128a850b1f514d28d72470ed50023df7d93bac83f268fdb472c15dfaf226c93c50da063ff92e1525b77e49661e87c8721a28db791fb90d94038089c6fed95ce3 1092 checksum: 4a66c00d07aa15ea229a1bb20025d240556d582f195e4d7ac5080a81c4fa45aa2cc294c0fbfc165d0d2acc0d4c545e0e7ba2d09f1cf0ed5ec8d7a38de4955aa3
1092 languageName: node 1093 languageName: node
1093 linkType: hard 1094 linkType: hard
1094 1095
1095"@mui/private-theming@npm:^5.4.2": 1096"@mui/private-theming@npm:^5.5.3":
1096 version: 5.4.2 1097 version: 5.5.3
1097 resolution: "@mui/private-theming@npm:5.4.2" 1098 resolution: "@mui/private-theming@npm:5.5.3"
1098 dependencies: 1099 dependencies:
1099 "@babel/runtime": ^7.17.0 1100 "@babel/runtime": ^7.17.2
1100 "@mui/utils": ^5.4.2 1101 "@mui/utils": ^5.5.3
1101 prop-types: ^15.7.2 1102 prop-types: ^15.7.2
1102 peerDependencies: 1103 peerDependencies:
1103 "@types/react": ^16.8.6 || ^17.0.0 1104 "@types/react": ^16.8.6 || ^17.0.0
@@ -1105,15 +1106,15 @@ __metadata:
1105 peerDependenciesMeta: 1106 peerDependenciesMeta:
1106 "@types/react": 1107 "@types/react":
1107 optional: true 1108 optional: true
1108 checksum: ae5050aa9d999c0645de80ce3af3a363ef508c55d87fe9a4f5c646dd892a825e999dda88bf555adc909fd5c398662810e51f1f018c11f96bd56ff876c1b6df40 1109 checksum: aa7a0cecc8cdc261650d8ba8639ce59ec2dd69b59cd326fc31c48e7fd7d49d85d2ddbabfb1b3a61d253b9d9c8a4dfdc6c537e0eff0469ea92e3570e0e3a776fa
1109 languageName: node 1110 languageName: node
1110 linkType: hard 1111 linkType: hard
1111 1112
1112"@mui/styled-engine@npm:^5.4.2": 1113"@mui/styled-engine@npm:^5.5.2":
1113 version: 5.4.2 1114 version: 5.5.2
1114 resolution: "@mui/styled-engine@npm:5.4.2" 1115 resolution: "@mui/styled-engine@npm:5.5.2"
1115 dependencies: 1116 dependencies:
1116 "@babel/runtime": ^7.17.0 1117 "@babel/runtime": ^7.17.2
1117 "@emotion/cache": ^11.7.1 1118 "@emotion/cache": ^11.7.1
1118 prop-types: ^15.7.2 1119 prop-types: ^15.7.2
1119 peerDependencies: 1120 peerDependencies:
@@ -1125,21 +1126,21 @@ __metadata:
1125 optional: true 1126 optional: true
1126 "@emotion/styled": 1127 "@emotion/styled":
1127 optional: true 1128 optional: true
1128 checksum: f7e73ddcec545bc43ccc95f67a2926ed6feb95762a8557b5ba9c002ac109964a957f20b6334f89657614ebad9bf6affe1b9d7d98610d29f24e294da63b0f7718 1129 checksum: 450bc9f16929e003f48aa2864b034fb24bc9804e67624bcd2b8a9e7cd8e8d4335250e2bd8383a43e4b18a3c6dea80390f2c656bfc6a25f405f6277b33f184cc5
1129 languageName: node 1130 languageName: node
1130 linkType: hard 1131 linkType: hard
1131 1132
1132"@mui/system@npm:^5.4.3": 1133"@mui/system@npm:^5.5.3":
1133 version: 5.4.3 1134 version: 5.5.3
1134 resolution: "@mui/system@npm:5.4.3" 1135 resolution: "@mui/system@npm:5.5.3"
1135 dependencies: 1136 dependencies:
1136 "@babel/runtime": ^7.17.0 1137 "@babel/runtime": ^7.17.2
1137 "@mui/private-theming": ^5.4.2 1138 "@mui/private-theming": ^5.5.3
1138 "@mui/styled-engine": ^5.4.2 1139 "@mui/styled-engine": ^5.5.2
1139 "@mui/types": ^7.1.2 1140 "@mui/types": ^7.1.3
1140 "@mui/utils": ^5.4.2 1141 "@mui/utils": ^5.5.3
1141 clsx: ^1.1.1 1142 clsx: ^1.1.1
1142 csstype: ^3.0.10 1143 csstype: ^3.0.11
1143 prop-types: ^15.7.2 1144 prop-types: ^15.7.2
1144 peerDependencies: 1145 peerDependencies:
1145 "@emotion/react": ^11.5.0 1146 "@emotion/react": ^11.5.0
@@ -1153,34 +1154,34 @@ __metadata:
1153 optional: true 1154 optional: true
1154 "@types/react": 1155 "@types/react":
1155 optional: true 1156 optional: true
1156 checksum: fe4776f113f787c3d4fa83ed06899738bf19e866b694a1c8b9459fb24ceede4102a73d6ef9c42d77550952dc0bd14b501157120359510c94da871c0553fb844c 1157 checksum: 7f2e7a1c4b1947e4d6a6efb70197f2978b45c56d09534e047c10d72c641dd338f1399972b7e47077d9e721ccb3ebe6e9161f122e59b99e7e39c1601f2d2c9f39
1157 languageName: node 1158 languageName: node
1158 linkType: hard 1159 linkType: hard
1159 1160
1160"@mui/types@npm:^7.1.2": 1161"@mui/types@npm:^7.1.3":
1161 version: 7.1.2 1162 version: 7.1.3
1162 resolution: "@mui/types@npm:7.1.2" 1163 resolution: "@mui/types@npm:7.1.3"
1163 peerDependencies: 1164 peerDependencies:
1164 "@types/react": "*" 1165 "@types/react": "*"
1165 peerDependenciesMeta: 1166 peerDependenciesMeta:
1166 "@types/react": 1167 "@types/react":
1167 optional: true 1168 optional: true
1168 checksum: 0d37947b0d2bcaed80b245ec4ac91bdff64ffedb5b0d56784311472b2c44131498ea700485f09dd3236baa97cd16af628e398453263e7081fabfe4fc1477249c 1169 checksum: 4990f505f1058bdd4c01ea21a6a6f788e5d3ff73b50962879d33bbf9c98ef1f18d8b6664025ce1dbd42544a79d7697d0011834f8fd83d12c9705f2c702829bb4
1169 languageName: node 1170 languageName: node
1170 linkType: hard 1171 linkType: hard
1171 1172
1172"@mui/utils@npm:^5.4.2": 1173"@mui/utils@npm:^5.5.3":
1173 version: 5.4.2 1174 version: 5.5.3
1174 resolution: "@mui/utils@npm:5.4.2" 1175 resolution: "@mui/utils@npm:5.5.3"
1175 dependencies: 1176 dependencies:
1176 "@babel/runtime": ^7.17.0 1177 "@babel/runtime": ^7.17.2
1177 "@types/prop-types": ^15.7.4 1178 "@types/prop-types": ^15.7.4
1178 "@types/react-is": ^16.7.1 || ^17.0.0 1179 "@types/react-is": ^16.7.1 || ^17.0.0
1179 prop-types: ^15.7.2 1180 prop-types: ^15.7.2
1180 react-is: ^17.0.2 1181 react-is: ^17.0.2
1181 peerDependencies: 1182 peerDependencies:
1182 react: ^17.0.0 1183 react: ^17.0.0
1183 checksum: 8d69572a2989bf11b897fb62b8c671968356b549dac285e99eab9cf8a9bce78a758926551fde8979f469b21a9647fc0dc42b49f0c0d30fe89d768c4eadf1e9a8 1184 checksum: 0c7c73aaeeb75792620a11aaca66c3c325777b16388559c7ac7ad60d80a9db82e09af40e76b62ae15c9d92a1d90aaad353e05e8774f3f50399f2af9ebb6ef4f5
1184 languageName: node 1185 languageName: node
1185 linkType: hard 1186 linkType: hard
1186 1187
@@ -1231,10 +1232,10 @@ __metadata:
1231 languageName: node 1232 languageName: node
1232 linkType: hard 1233 linkType: hard
1233 1234
1234"@popperjs/core@npm:^2.4.4": 1235"@popperjs/core@npm:^2.11.4":
1235 version: 2.11.0 1236 version: 2.11.4
1236 resolution: "@popperjs/core@npm:2.11.0" 1237 resolution: "@popperjs/core@npm:2.11.4"
1237 checksum: 84d6f197d3ddfd8a5b05c7276c3692d8404c96128a946ab0a800b25567d8fc231928319c1f97a67e0817e76ce2a1b735589ef0f38f8e8835692408660a2395a1 1238 checksum: 36168d274aa164368a50aef2e7b2f858e1b9145d9250af9dc1315ff719d874a367177760f8285efb75c8cf48267194a50e7dc9cdb41bf0b1958c38805c13564c
1238 languageName: node 1239 languageName: node
1239 linkType: hard 1240 linkType: hard
1240 1241
@@ -1320,6 +1321,7 @@ __metadata:
1320 "@sophie/shared": "workspace:*" 1321 "@sophie/shared": "workspace:*"
1321 "@types/jest": ^27.4.1 1322 "@types/jest": ^27.4.1
1322 electron: 17.1.0 1323 electron: 17.1.0
1324 i18next: ^21.6.14
1323 jest: ^27.5.1 1325 jest: ^27.5.1
1324 jest-mock: ^27.5.1 1326 jest-mock: ^27.5.1
1325 jsdom: ^19.0.0 1327 jsdom: ^19.0.0
@@ -1345,6 +1347,7 @@ __metadata:
1345 "@types/react": ^17.0.39 1347 "@types/react": ^17.0.39
1346 "@types/react-dom": ^17.0.11 1348 "@types/react-dom": ^17.0.11
1347 "@vitejs/plugin-react": ^1.2.0 1349 "@vitejs/plugin-react": ^1.2.0
1350 i18next: ^21.6.14
1348 jest: ^27.5.1 1351 jest: ^27.5.1
1349 jest-mock: ^27.5.1 1352 jest-mock: ^27.5.1
1350 jsdom: ^19.0.0 1353 jsdom: ^19.0.0
@@ -1357,6 +1360,7 @@ __metadata:
1357 mst-middlewares: ^5.1.3 1360 mst-middlewares: ^5.1.3
1358 react: ^17.0.2 1361 react: ^17.0.2
1359 react-dom: ^17.0.2 1362 react-dom: ^17.0.2
1363 react-i18next: ^11.16.2
1360 remotedev: ^0.2.9 1364 remotedev: ^0.2.9
1361 vite: ^2.8.4 1365 vite: ^2.8.4
1362 languageName: unknown 1366 languageName: unknown
@@ -1393,6 +1397,7 @@ __metadata:
1393 version: 0.0.0-use.local 1397 version: 0.0.0-use.local
1394 resolution: "@sophie/shared@workspace:packages/shared" 1398 resolution: "@sophie/shared@workspace:packages/shared"
1395 dependencies: 1399 dependencies:
1400 i18next: ^21.6.14
1396 mobx: ^6.4.1 1401 mobx: ^6.4.1
1397 mobx-state-tree: ^5.1.3 1402 mobx-state-tree: ^5.1.3
1398 zod: ^3.12.0 1403 zod: ^3.12.0
@@ -3129,7 +3134,14 @@ __metadata:
3129 languageName: node 3134 languageName: node
3130 linkType: hard 3135 linkType: hard
3131 3136
3132"csstype@npm:^3.0.10, csstype@npm:^3.0.2": 3137"csstype@npm:^3.0.11":
3138 version: 3.0.11
3139 resolution: "csstype@npm:3.0.11"
3140 checksum: 95e56abfe9ca219ae065acb4e43f61771a03170eed919127f558dfa168240867aba7629c8d98a201a0dd06d9a5ce82686f0570031c928516c61816adbc7c877f
3141 languageName: node
3142 linkType: hard
3143
3144"csstype@npm:^3.0.2":
3133 version: 3.0.10 3145 version: 3.0.10
3134 resolution: "csstype@npm:3.0.10" 3146 resolution: "csstype@npm:3.0.10"
3135 checksum: 20a8fa324f2b33ddf94aa7507d1b6ab3daa6f3cc308888dc50126585d7952f2471de69b2dbe0635d1fdc31223fef8e070842691877e725caf456e2378685a631 3147 checksum: 20a8fa324f2b33ddf94aa7507d1b6ab3daa6f3cc308888dc50126585d7952f2471de69b2dbe0635d1fdc31223fef8e070842691877e725caf456e2378685a631
@@ -4991,13 +5003,22 @@ __metadata:
4991 languageName: node 5003 languageName: node
4992 linkType: hard 5004 linkType: hard
4993 5005
4994"html-escaper@npm:^2.0.0": 5006"html-escaper@npm:^2.0.0, html-escaper@npm:^2.0.2":
4995 version: 2.0.2 5007 version: 2.0.2
4996 resolution: "html-escaper@npm:2.0.2" 5008 resolution: "html-escaper@npm:2.0.2"
4997 checksum: d2df2da3ad40ca9ee3a39c5cc6475ef67c8f83c234475f24d8e9ce0dc80a2c82df8e1d6fa78ddd1e9022a586ea1bd247a615e80a5cd9273d90111ddda7d9e974 5009 checksum: d2df2da3ad40ca9ee3a39c5cc6475ef67c8f83c234475f24d8e9ce0dc80a2c82df8e1d6fa78ddd1e9022a586ea1bd247a615e80a5cd9273d90111ddda7d9e974
4998 languageName: node 5010 languageName: node
4999 linkType: hard 5011 linkType: hard
5000 5012
5013"html-parse-stringify@npm:^3.0.1":
5014 version: 3.0.1
5015 resolution: "html-parse-stringify@npm:3.0.1"
5016 dependencies:
5017 void-elements: 3.1.0
5018 checksum: 334fdebd4b5c355dba8e95284cead6f62bf865a2359da2759b039db58c805646350016d2017875718bc3c4b9bf81a0d11be5ee0cf4774a3a5a7b97cde21cfd67
5019 languageName: node
5020 linkType: hard
5021
5001"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.0": 5022"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.0":
5002 version: 4.1.0 5023 version: 4.1.0
5003 resolution: "http-cache-semantics@npm:4.1.0" 5024 resolution: "http-cache-semantics@npm:4.1.0"
@@ -7558,6 +7579,25 @@ __metadata:
7558 languageName: node 7579 languageName: node
7559 linkType: hard 7580 linkType: hard
7560 7581
7582"react-i18next@npm:^11.16.2":
7583 version: 11.16.2
7584 resolution: "react-i18next@npm:11.16.2"
7585 dependencies:
7586 "@babel/runtime": ^7.14.5
7587 html-escaper: ^2.0.2
7588 html-parse-stringify: ^3.0.1
7589 peerDependencies:
7590 i18next: ">= 19.0.0"
7591 react: ">= 16.8.0"
7592 peerDependenciesMeta:
7593 react-dom:
7594 optional: true
7595 react-native:
7596 optional: true
7597 checksum: 3e86c5e7a73eef88eff7487bbc87ecf3d5c6808ce3d0cc1e9599e6a1b72466d2d6ced2b1b600c18a2309757bea49d1670526a1bc8953a9bf377964e9ea3c9166
7598 languageName: node
7599 linkType: hard
7600
7561"react-is@npm:^16.7.0, react-is@npm:^16.8.1": 7601"react-is@npm:^16.7.0, react-is@npm:^16.8.1":
7562 version: 16.13.1 7602 version: 16.13.1
7563 resolution: "react-is@npm:16.13.1" 7603 resolution: "react-is@npm:16.13.1"
@@ -9048,6 +9088,13 @@ __metadata:
9048 languageName: node 9088 languageName: node
9049 linkType: hard 9089 linkType: hard
9050 9090
9091"void-elements@npm:3.1.0":
9092 version: 3.1.0
9093 resolution: "void-elements@npm:3.1.0"
9094 checksum: 0390f818107fa8fce55bb0a5c3f661056001c1d5a2a48c28d582d4d847347c2ab5b7f8272314cac58acf62345126b6b09bea623a185935f6b1c3bbce0dfd7f7f
9095 languageName: node
9096 linkType: hard
9097
9051"w3c-hr-time@npm:^1.0.2": 9098"w3c-hr-time@npm:^1.0.2":
9052 version: 1.0.2 9099 version: 1.0.2
9053 resolution: "w3c-hr-time@npm:1.0.2" 9100 resolution: "w3c-hr-time@npm:1.0.2"