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