aboutsummaryrefslogtreecommitdiffstats
path: root/packages/main/src
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-03-30 21:47:45 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-05-16 00:54:57 +0200
commit85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9 (patch)
tree277ab45a66a1c74e2d0a885c8a354aea27128d12 /packages/main/src
parentfeat(main): Translation hot reloading during development (diff)
downloadsophie-85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9.tar.gz
sophie-85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9.tar.zst
sophie-85d91c64b5b3ec31df8acecd68a1fa6a68d57ff9.zip
feat(renderer): Renderer translations
Add react-i18n to make us able to use i18next translations in the renderer process just like we do in the main process. Translations are hot-reloaded automatically. Signed-off-by: Kristóf Marussy <kristof@marussy.com>
Diffstat (limited to 'packages/main/src')
-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
6 files changed, 129 insertions, 28 deletions
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/*