diff options
author | 2022-06-22 00:32:18 +0200 | |
---|---|---|
committer | 2022-06-21 22:32:18 +0000 | |
commit | 73ba955e344c8ccedd43235495ef8b72b5a2b6fd (patch) | |
tree | 03766ab32fefe7e83026a14393527f1dcbaed849 /src/stores/AppStore.js | |
parent | docs: add cino as a contributor for infra [skip ci] (#330) (diff) | |
download | ferdium-app-73ba955e344c8ccedd43235495ef8b72b5a2b6fd.tar.gz ferdium-app-73ba955e344c8ccedd43235495ef8b72b5a2b6fd.tar.zst ferdium-app-73ba955e344c8ccedd43235495ef8b72b5a2b6fd.zip |
chore: Transform AppStore.js into Typescript (#329)
* turn actions into typescript
* correct tsconfig
* added TypedStore
Diffstat (limited to 'src/stores/AppStore.js')
-rw-r--r-- | src/stores/AppStore.js | 573 |
1 files changed, 0 insertions, 573 deletions
diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js deleted file mode 100644 index a8e1ce247..000000000 --- a/src/stores/AppStore.js +++ /dev/null | |||
@@ -1,573 +0,0 @@ | |||
1 | import { ipcRenderer } from 'electron'; | ||
2 | import { | ||
3 | app, | ||
4 | screen, | ||
5 | powerMonitor, | ||
6 | nativeTheme, | ||
7 | getCurrentWindow, | ||
8 | process as remoteProcess, | ||
9 | } from '@electron/remote'; | ||
10 | import { action, computed, observable } from 'mobx'; | ||
11 | import moment from 'moment'; | ||
12 | import AutoLaunch from 'auto-launch'; | ||
13 | import ms from 'ms'; | ||
14 | import { URL } from 'url'; | ||
15 | import { readJsonSync } from 'fs-extra'; | ||
16 | |||
17 | import Store from './lib/Store'; | ||
18 | import Request from './lib/Request'; | ||
19 | import { CHECK_INTERVAL, DEFAULT_APP_SETTINGS } from '../config'; | ||
20 | import { cleanseJSObject } from '../jsUtils'; | ||
21 | import { isMac, isWindows, electronVersion, osRelease } from '../environment'; | ||
22 | import { ferdiumVersion, userDataPath, ferdiumLocale } from '../environment-remote'; | ||
23 | import { generatedTranslations } from '../i18n/translations'; | ||
24 | import { getLocale } from '../helpers/i18n-helpers'; | ||
25 | |||
26 | import { | ||
27 | getServiceIdsFromPartitions, | ||
28 | removeServicePartitionDirectory, | ||
29 | } from '../helpers/service-helpers'; | ||
30 | import { openExternalUrl } from '../helpers/url-helpers'; | ||
31 | import { sleep } from '../helpers/async-helpers'; | ||
32 | |||
33 | const debug = require('../preload-safe-debug')('Ferdium:AppStore'); | ||
34 | |||
35 | const mainWindow = getCurrentWindow(); | ||
36 | |||
37 | const executablePath = isMac ? remoteProcess.execPath : process.execPath; | ||
38 | const autoLauncher = new AutoLaunch({ | ||
39 | name: 'Ferdium', | ||
40 | path: executablePath, | ||
41 | }); | ||
42 | |||
43 | const CATALINA_NOTIFICATION_HACK_KEY = | ||
44 | '_temp_askedForCatalinaNotificationPermissions'; | ||
45 | |||
46 | const locales = generatedTranslations(); | ||
47 | |||
48 | export default class AppStore extends Store { | ||
49 | updateStatusTypes = { | ||
50 | CHECKING: 'CHECKING', | ||
51 | AVAILABLE: 'AVAILABLE', | ||
52 | NOT_AVAILABLE: 'NOT_AVAILABLE', | ||
53 | DOWNLOADED: 'DOWNLOADED', | ||
54 | FAILED: 'FAILED', | ||
55 | }; | ||
56 | |||
57 | @observable healthCheckRequest = new Request(this.api.app, 'health'); | ||
58 | |||
59 | @observable getAppCacheSizeRequest = new Request( | ||
60 | this.api.local, | ||
61 | 'getAppCacheSize', | ||
62 | ); | ||
63 | |||
64 | @observable clearAppCacheRequest = new Request(this.api.local, 'clearCache'); | ||
65 | |||
66 | @observable autoLaunchOnStart = true; | ||
67 | |||
68 | @observable isOnline = navigator.onLine; | ||
69 | |||
70 | @observable authRequestFailed = false; | ||
71 | |||
72 | @observable timeSuspensionStart = moment(); | ||
73 | |||
74 | @observable timeOfflineStart; | ||
75 | |||
76 | @observable updateStatus = ''; | ||
77 | |||
78 | @observable locale = ferdiumLocale; | ||
79 | |||
80 | @observable isSystemMuteOverridden = false; | ||
81 | |||
82 | @observable isSystemDarkModeEnabled = false; | ||
83 | |||
84 | @observable isClearingAllCache = false; | ||
85 | |||
86 | @observable isFullScreen = mainWindow.isFullScreen(); | ||
87 | |||
88 | @observable isFocused = true; | ||
89 | |||
90 | dictionaries = []; | ||
91 | |||
92 | fetchDataInterval = null; | ||
93 | |||
94 | constructor(...args) { | ||
95 | super(...args); | ||
96 | |||
97 | // Register action handlers | ||
98 | this.actions.app.notify.listen(this._notify.bind(this)); | ||
99 | this.actions.app.setBadge.listen(this._setBadge.bind(this)); | ||
100 | this.actions.app.launchOnStartup.listen(this._launchOnStartup.bind(this)); | ||
101 | this.actions.app.openExternalUrl.listen(this._openExternalUrl.bind(this)); | ||
102 | this.actions.app.checkForUpdates.listen(this._checkForUpdates.bind(this)); | ||
103 | this.actions.app.installUpdate.listen(this._installUpdate.bind(this)); | ||
104 | this.actions.app.resetUpdateStatus.listen( | ||
105 | this._resetUpdateStatus.bind(this), | ||
106 | ); | ||
107 | this.actions.app.healthCheck.listen(this._healthCheck.bind(this)); | ||
108 | this.actions.app.muteApp.listen(this._muteApp.bind(this)); | ||
109 | this.actions.app.toggleMuteApp.listen(this._toggleMuteApp.bind(this)); | ||
110 | this.actions.app.clearAllCache.listen(this._clearAllCache.bind(this)); | ||
111 | |||
112 | this.registerReactions([ | ||
113 | this._offlineCheck.bind(this), | ||
114 | this._setLocale.bind(this), | ||
115 | this._muteAppHandler.bind(this), | ||
116 | this._handleFullScreen.bind(this), | ||
117 | this._handleLogout.bind(this), | ||
118 | ]); | ||
119 | } | ||
120 | |||
121 | async setup() { | ||
122 | this._appStartsCounter(); | ||
123 | // Focus the active service | ||
124 | window.addEventListener('focus', this.actions.service.focusActiveService); | ||
125 | |||
126 | // Online/Offline handling | ||
127 | window.addEventListener('online', () => { | ||
128 | this.isOnline = true; | ||
129 | }); | ||
130 | window.addEventListener('offline', () => { | ||
131 | this.isOnline = false; | ||
132 | }); | ||
133 | |||
134 | mainWindow.on('enter-full-screen', () => { | ||
135 | this.isFullScreen = true; | ||
136 | }); | ||
137 | mainWindow.on('leave-full-screen', () => { | ||
138 | this.isFullScreen = false; | ||
139 | }); | ||
140 | |||
141 | this.isOnline = navigator.onLine; | ||
142 | |||
143 | // Check if Ferdium should launch on start | ||
144 | // Needs to be delayed a bit | ||
145 | this._autoStart(); | ||
146 | |||
147 | // Check if system is muted | ||
148 | // There are no events to subscribe so we need to poll everey 5s | ||
149 | this._systemDND(); | ||
150 | setInterval(() => this._systemDND(), ms('5s')); | ||
151 | |||
152 | this.fetchDataInterval = setInterval(() => { | ||
153 | this.stores.user.getUserInfoRequest.invalidate({ | ||
154 | immediately: true, | ||
155 | }); | ||
156 | this.stores.features.featuresRequest.invalidate({ | ||
157 | immediately: true, | ||
158 | }); | ||
159 | }, ms('60m')); | ||
160 | |||
161 | // Check for updates once every 4 hours | ||
162 | setInterval(() => this._checkForUpdates(), CHECK_INTERVAL); | ||
163 | // Check for an update in 30s (need a delay to prevent Squirrel Installer lock file issues) | ||
164 | setTimeout(() => this._checkForUpdates(), ms('30s')); | ||
165 | ipcRenderer.on('autoUpdate', (event, data) => { | ||
166 | if (this.updateStatus !== this.updateStatusTypes.FAILED) { | ||
167 | if (data.available) { | ||
168 | this.updateStatus = this.updateStatusTypes.AVAILABLE; | ||
169 | if (isMac && this.stores.settings.app.automaticUpdates) { | ||
170 | app.dock.bounce(); | ||
171 | } | ||
172 | } | ||
173 | |||
174 | if (data.available !== undefined && !data.available) { | ||
175 | this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; | ||
176 | } | ||
177 | |||
178 | if (data.downloaded) { | ||
179 | this.updateStatus = this.updateStatusTypes.DOWNLOADED; | ||
180 | if (isMac && this.stores.settings.app.automaticUpdates) { | ||
181 | app.dock.bounce(); | ||
182 | } | ||
183 | } | ||
184 | |||
185 | if (data.error) { | ||
186 | if (data.error.message && data.error.message.startsWith('404')) { | ||
187 | this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; | ||
188 | console.warn('Updater warning: there seems to be unpublished pre-release(s) available on GitHub', data.error); | ||
189 | } else { | ||
190 | console.error('Updater error:', data.error); | ||
191 | this.updateStatus = this.updateStatusTypes.FAILED; | ||
192 | } | ||
193 | } | ||
194 | } | ||
195 | }); | ||
196 | |||
197 | // Handle deep linking (ferdium://) | ||
198 | ipcRenderer.on('navigateFromDeepLink', (event, data) => { | ||
199 | debug('Navigate from deep link', data); | ||
200 | let { url } = data; | ||
201 | if (!url) return; | ||
202 | |||
203 | url = url.replace(/\/$/, ''); | ||
204 | |||
205 | this.stores.router.push(url); | ||
206 | }); | ||
207 | |||
208 | ipcRenderer.on('muteApp', () => { | ||
209 | this._toggleMuteApp(); | ||
210 | }); | ||
211 | |||
212 | this.locale = this._getDefaultLocale(); | ||
213 | |||
214 | setTimeout(() => { | ||
215 | this._healthCheck(); | ||
216 | }, 1000); | ||
217 | |||
218 | this.isSystemDarkModeEnabled = nativeTheme.shouldUseDarkColors; | ||
219 | |||
220 | ipcRenderer.on('isWindowFocused', (event, isFocused) => { | ||
221 | debug('Setting is focused to', isFocused); | ||
222 | this.isFocused = isFocused; | ||
223 | }); | ||
224 | |||
225 | powerMonitor.on('suspend', () => { | ||
226 | debug('System suspended starting timer'); | ||
227 | |||
228 | this.timeSuspensionStart = moment(); | ||
229 | }); | ||
230 | |||
231 | powerMonitor.on('resume', () => { | ||
232 | debug('System resumed, last suspended on', this.timeSuspensionStart); | ||
233 | this.actions.service.resetLastPollTimer(); | ||
234 | |||
235 | const idleTime = this.stores.settings.app.reloadAfterResumeTime; | ||
236 | |||
237 | if ( | ||
238 | this.timeSuspensionStart.add(idleTime, 'm').isBefore(moment()) && | ||
239 | this.stores.settings.app.reloadAfterResume | ||
240 | ) { | ||
241 | debug('Reloading services, user info and features'); | ||
242 | |||
243 | setInterval(() => { | ||
244 | debug('Reload app interval is starting'); | ||
245 | if (this.isOnline) { | ||
246 | window.location.reload(); | ||
247 | } | ||
248 | }, ms('2s')); | ||
249 | } | ||
250 | }); | ||
251 | |||
252 | // macOS catalina notifications hack | ||
253 | // notifications got stuck after upgrade but forcing a notification | ||
254 | // via `new Notification` triggered the permission request | ||
255 | if (isMac && !localStorage.getItem(CATALINA_NOTIFICATION_HACK_KEY)) { | ||
256 | debug('Triggering macOS Catalina notification permission trigger'); | ||
257 | // eslint-disable-next-line no-new | ||
258 | new window.Notification('Welcome to Ferdium 5', { | ||
259 | body: 'Have a wonderful day & happy messaging.', | ||
260 | }); | ||
261 | |||
262 | localStorage.setItem(CATALINA_NOTIFICATION_HACK_KEY, 'true'); | ||
263 | } | ||
264 | } | ||
265 | |||
266 | @computed get cacheSize() { | ||
267 | return this.getAppCacheSizeRequest.execute().result; | ||
268 | } | ||
269 | |||
270 | @computed get debugInfo() { | ||
271 | const settings = cleanseJSObject(this.stores.settings.app); | ||
272 | settings.lockedPassword = '******'; | ||
273 | |||
274 | return { | ||
275 | host: { | ||
276 | platform: process.platform, | ||
277 | release: osRelease, | ||
278 | screens: screen.getAllDisplays(), | ||
279 | }, | ||
280 | ferdium: { | ||
281 | version: ferdiumVersion, | ||
282 | electron: electronVersion, | ||
283 | installedRecipes: this.stores.recipes.all.map(recipe => ({ | ||
284 | id: recipe.id, | ||
285 | version: recipe.version, | ||
286 | })), | ||
287 | devRecipes: this.stores.recipePreviews.dev.map(recipe => ({ | ||
288 | id: recipe.id, | ||
289 | version: recipe.version, | ||
290 | })), | ||
291 | services: this.stores.services.all.map(service => ({ | ||
292 | id: service.id, | ||
293 | recipe: service.recipe.id, | ||
294 | isAttached: service.isAttached, | ||
295 | isActive: service.isActive, | ||
296 | isEnabled: service.isEnabled, | ||
297 | isHibernating: service.isHibernating, | ||
298 | hasCrashed: service.hasCrashed, | ||
299 | isDarkModeEnabled: service.isDarkModeEnabled, | ||
300 | isProgressbarEnabled: service.isProgressbarEnabled, | ||
301 | })), | ||
302 | messages: this.stores.globalError.messages, | ||
303 | workspaces: this.stores.workspaces.workspaces.map(workspace => ({ | ||
304 | id: workspace.id, | ||
305 | services: workspace.services, | ||
306 | })), | ||
307 | windowSettings: readJsonSync(userDataPath('window-state.json')), | ||
308 | settings, | ||
309 | features: this.stores.features.features, | ||
310 | user: this.stores.user.data.id, | ||
311 | }, | ||
312 | }; | ||
313 | } | ||
314 | |||
315 | // Actions | ||
316 | @action _notify({ title, options, notificationId, serviceId = null }) { | ||
317 | if (this.stores.settings.all.app.isAppMuted) return; | ||
318 | |||
319 | // TODO: is there a simple way to use blobs for notifications without storing them on disk? | ||
320 | if (options.icon && options.icon.startsWith('blob:')) { | ||
321 | delete options.icon; | ||
322 | } | ||
323 | |||
324 | const notification = new window.Notification(title, options); | ||
325 | |||
326 | debug('New notification', title, options); | ||
327 | |||
328 | notification.addEventListener('click', () => { | ||
329 | if (serviceId) { | ||
330 | this.actions.service.sendIPCMessage({ | ||
331 | channel: `notification-onclick:${notificationId}`, | ||
332 | args: {}, | ||
333 | serviceId, | ||
334 | }); | ||
335 | |||
336 | this.actions.service.setActive({ | ||
337 | serviceId, | ||
338 | }); | ||
339 | |||
340 | if (!mainWindow.isVisible()) { | ||
341 | mainWindow.show(); | ||
342 | } | ||
343 | if (mainWindow.isMinimized()) { | ||
344 | mainWindow.restore(); | ||
345 | } | ||
346 | mainWindow.focus(); | ||
347 | |||
348 | debug('Notification click handler'); | ||
349 | } | ||
350 | }); | ||
351 | } | ||
352 | |||
353 | @action _setBadge({ unreadDirectMessageCount, unreadIndirectMessageCount }) { | ||
354 | let indicator = unreadDirectMessageCount; | ||
355 | |||
356 | if (indicator === 0 && unreadIndirectMessageCount !== 0) { | ||
357 | indicator = '•'; | ||
358 | } else if ( | ||
359 | unreadDirectMessageCount === 0 && | ||
360 | unreadIndirectMessageCount === 0 | ||
361 | ) { | ||
362 | indicator = 0; | ||
363 | } else { | ||
364 | indicator = Number.parseInt(indicator, 10); | ||
365 | } | ||
366 | |||
367 | ipcRenderer.send('updateAppIndicator', { | ||
368 | indicator, | ||
369 | }); | ||
370 | } | ||
371 | |||
372 | @action _launchOnStartup({ enable }) { | ||
373 | this.autoLaunchOnStart = enable; | ||
374 | |||
375 | try { | ||
376 | if (enable) { | ||
377 | debug('enabling launch on startup', executablePath); | ||
378 | autoLauncher.enable(); | ||
379 | } else { | ||
380 | debug('disabling launch on startup'); | ||
381 | autoLauncher.disable(); | ||
382 | } | ||
383 | } catch (error) { | ||
384 | console.warn(error); | ||
385 | } | ||
386 | } | ||
387 | |||
388 | // Ideally(?) this should be merged with the 'shell-helpers' functionality | ||
389 | @action _openExternalUrl({ url }) { | ||
390 | openExternalUrl(new URL(url)); | ||
391 | } | ||
392 | |||
393 | @action _checkForUpdates() { | ||
394 | if (this.isOnline && this.stores.settings.app.automaticUpdates && (isMac || isWindows || process.env.APPIMAGE)) { | ||
395 | debug('_checkForUpdates: sending event to autoUpdate:check'); | ||
396 | this.updateStatus = this.updateStatusTypes.CHECKING; | ||
397 | ipcRenderer.send('autoUpdate', { | ||
398 | action: 'check', | ||
399 | }); | ||
400 | } | ||
401 | |||
402 | if (this.isOnline && this.stores.settings.app.automaticUpdates) { | ||
403 | this.actions.recipe.update(); | ||
404 | } | ||
405 | } | ||
406 | |||
407 | @action _installUpdate() { | ||
408 | debug('_installUpdate: sending event to autoUpdate:install'); | ||
409 | ipcRenderer.send('autoUpdate', { | ||
410 | action: 'install', | ||
411 | }); | ||
412 | } | ||
413 | |||
414 | @action _resetUpdateStatus() { | ||
415 | this.updateStatus = ''; | ||
416 | } | ||
417 | |||
418 | @action _healthCheck() { | ||
419 | this.healthCheckRequest.execute(); | ||
420 | } | ||
421 | |||
422 | @action _muteApp({ isMuted, overrideSystemMute = true }) { | ||
423 | this.isSystemMuteOverridden = overrideSystemMute; | ||
424 | this.actions.settings.update({ | ||
425 | type: 'app', | ||
426 | data: { | ||
427 | isAppMuted: isMuted, | ||
428 | }, | ||
429 | }); | ||
430 | } | ||
431 | |||
432 | @action _toggleMuteApp() { | ||
433 | this._muteApp({ | ||
434 | isMuted: !this.stores.settings.all.app.isAppMuted, | ||
435 | }); | ||
436 | } | ||
437 | |||
438 | @action async _clearAllCache() { | ||
439 | this.isClearingAllCache = true; | ||
440 | const clearAppCache = this.clearAppCacheRequest.execute(); | ||
441 | const allServiceIds = await getServiceIdsFromPartitions(); | ||
442 | const allOrphanedServiceIds = allServiceIds.filter( | ||
443 | id => | ||
444 | !this.stores.services.all.some( | ||
445 | s => id.replace('service-', '') === s.id, | ||
446 | ), | ||
447 | ); | ||
448 | |||
449 | try { | ||
450 | await Promise.all( | ||
451 | allOrphanedServiceIds.map(id => removeServicePartitionDirectory(id)), | ||
452 | ); | ||
453 | } catch (error) { | ||
454 | console.log('Error while deleting service partition directory -', error); | ||
455 | } | ||
456 | await Promise.all( | ||
457 | this.stores.services.all.map(s => | ||
458 | this.actions.service.clearCache({ | ||
459 | serviceId: s.id, | ||
460 | }), | ||
461 | ), | ||
462 | ); | ||
463 | |||
464 | await clearAppCache._promise; | ||
465 | |||
466 | await sleep(ms('1s')); | ||
467 | |||
468 | this.getAppCacheSizeRequest.execute(); | ||
469 | |||
470 | this.isClearingAllCache = false; | ||
471 | } | ||
472 | |||
473 | // Reactions | ||
474 | _offlineCheck() { | ||
475 | if (!this.isOnline) { | ||
476 | this.timeOfflineStart = moment(); | ||
477 | } else { | ||
478 | const deltaTime = moment().diff(this.timeOfflineStart); | ||
479 | |||
480 | if (deltaTime > ms('30m')) { | ||
481 | this.actions.service.reloadAll(); | ||
482 | } | ||
483 | } | ||
484 | } | ||
485 | |||
486 | _setLocale() { | ||
487 | if (this.stores.user.isLoggedIn && this.stores.user.data.locale) { | ||
488 | this.locale = this.stores.user.data.locale; | ||
489 | } else if (!this.locale) { | ||
490 | this.locale = this._getDefaultLocale(); | ||
491 | } | ||
492 | |||
493 | moment.locale(this.locale); | ||
494 | debug(`Set locale to "${this.locale}"`); | ||
495 | } | ||
496 | |||
497 | _getDefaultLocale() { | ||
498 | return getLocale({ | ||
499 | locale: ferdiumLocale, | ||
500 | locales, | ||
501 | fallbackLocale: DEFAULT_APP_SETTINGS.fallbackLocale, | ||
502 | }); | ||
503 | } | ||
504 | |||
505 | _muteAppHandler() { | ||
506 | const { showMessageBadgesEvenWhenMuted } = this.stores.ui; | ||
507 | |||
508 | if (!showMessageBadgesEvenWhenMuted) { | ||
509 | this.actions.app.setBadge({ | ||
510 | unreadDirectMessageCount: 0, | ||
511 | unreadIndirectMessageCount: 0, | ||
512 | }); | ||
513 | } | ||
514 | } | ||
515 | |||
516 | _handleFullScreen() { | ||
517 | const body = document.querySelector('body'); | ||
518 | |||
519 | if (body) { | ||
520 | if (this.isFullScreen) { | ||
521 | body.classList.add('isFullScreen'); | ||
522 | } else { | ||
523 | body.classList.remove('isFullScreen'); | ||
524 | } | ||
525 | } | ||
526 | } | ||
527 | |||
528 | _handleLogout() { | ||
529 | if (!this.stores.user.isLoggedIn) { | ||
530 | clearInterval(this.fetchDataInterval); | ||
531 | } | ||
532 | } | ||
533 | |||
534 | // Helpers | ||
535 | _appStartsCounter() { | ||
536 | this.actions.settings.update({ | ||
537 | type: 'stats', | ||
538 | data: { | ||
539 | appStarts: (this.stores.settings.all.stats.appStarts || 0) + 1, | ||
540 | }, | ||
541 | }); | ||
542 | } | ||
543 | |||
544 | async _autoStart() { | ||
545 | this.autoLaunchOnStart = await this._checkAutoStart(); | ||
546 | |||
547 | if (this.stores.settings.all.stats.appStarts === 1) { | ||
548 | debug('Set app to launch on start'); | ||
549 | this.actions.app.launchOnStartup({ | ||
550 | enable: true, | ||
551 | }); | ||
552 | } | ||
553 | } | ||
554 | |||
555 | async _checkAutoStart() { | ||
556 | return autoLauncher.isEnabled() || false; | ||
557 | } | ||
558 | |||
559 | async _systemDND() { | ||
560 | debug('Checking if Do Not Disturb Mode is on'); | ||
561 | const dnd = await ipcRenderer.invoke('get-dnd'); | ||
562 | debug('Do not disturb mode is', dnd); | ||
563 | if ( | ||
564 | dnd !== this.stores.settings.all.app.isAppMuted && | ||
565 | !this.isSystemMuteOverridden | ||
566 | ) { | ||
567 | this.actions.app.muteApp({ | ||
568 | isMuted: dnd, | ||
569 | overrideSystemMute: false, | ||
570 | }); | ||
571 | } | ||
572 | } | ||
573 | } | ||