aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-04-24 17:01:25 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-05-16 00:55:02 +0200
commit0b632445a933644c7eb10015eb04b7c21d562900 (patch)
tree5bd1fe200cc747086220dbcba90097bfe2cc4cdc
parentchore(deps): upgrade dependencies (diff)
downloadsophie-0b632445a933644c7eb10015eb04b7c21d562900.tar.gz
sophie-0b632445a933644c7eb10015eb04b7c21d562900.tar.zst
sophie-0b632445a933644c7eb10015eb04b7c21d562900.zip
refactor: reduce service switcher tearing
We render the location bar and notification banners separately for each service and keep track of the BrowserView size separately for each service to reduce the tearing that appears when people switch services. The tearing cannot be eliminated completely, because it comes from the separation between the main and renderer processes. But we can at least try and reduce the IPC round-tripping and layout calculations required to accurately position the services. This approach has an overhead compared to the single BrowserViewPlaceholder approach, because the renderer process has to layout the location bar and notification for all services, not only the selected one. The number of IPC messages during windows resize is also increased. To compensate, we increase the throttle interval for resize IPC messages and let electron itself resize the BrowserView between IPC updates. (We must still keep pumping IPC messages during window resize, because, e.g., changes in notification banner size due to re-layouting will still affect the required BrowserView size). If further reduction of IPC traffic is needed, we could implement batching for resize IPC messages and more intelligent throttling via a token bucker mechanism. Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts3
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts20
-rw-r--r--packages/main/src/infrastructure/electron/types.ts4
-rw-r--r--packages/main/src/reactions/loadServices.ts8
-rw-r--r--packages/main/src/stores/MainStore.ts19
-rw-r--r--packages/main/src/stores/Service.ts26
-rw-r--r--packages/renderer/src/components/App.tsx22
-rw-r--r--packages/renderer/src/components/BrowserViewPlaceholder.tsx14
-rw-r--r--packages/renderer/src/components/ServicePanel.tsx76
-rw-r--r--packages/renderer/src/components/banner/InsecureConnectionBanner.tsx7
-rw-r--r--packages/renderer/src/components/banner/NewWindowBanner.tsx7
-rw-r--r--packages/renderer/src/components/errorPage/ErrorPage.tsx8
-rw-r--r--packages/renderer/src/components/locationBar/ExtraButtons.tsx10
-rw-r--r--packages/renderer/src/components/locationBar/LocationBar.tsx24
-rw-r--r--packages/renderer/src/components/locationBar/LocationTextField.tsx21
-rw-r--r--packages/renderer/src/components/locationBar/NavigationButtons.tsx27
-rw-r--r--packages/renderer/src/components/sidebar/ServiceSwitcher.tsx2
-rw-r--r--packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx8
-rw-r--r--packages/renderer/src/stores/RendererStore.ts16
-rw-r--r--packages/renderer/src/stores/Service.ts12
-rw-r--r--packages/shared/src/schemas/Action.ts5
-rw-r--r--packages/shared/src/schemas/ServiceAction.ts6
-rw-r--r--packages/shared/src/stores/SharedStoreBase.ts5
23 files changed, 209 insertions, 141 deletions
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts b/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts
index edc6592..b0db115 100644
--- a/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts
+++ b/packages/main/src/infrastructure/electron/impl/ElectronMainWindow.ts
@@ -153,6 +153,9 @@ export default class ElectronMainWindow implements MainWindow {
153 } 153 }
154 if (serviceView instanceof ElectronServiceView) { 154 if (serviceView instanceof ElectronServiceView) {
155 this.browserWindow.setBrowserView(serviceView.browserView); 155 this.browserWindow.setBrowserView(serviceView.browserView);
156 // If this `BrowserView` hasn't been attached previously,
157 // we must update its bounds _after_ attaching for the resizing to take effect.
158 serviceView.updateBounds();
156 return; 159 return;
157 } 160 }
158 throw new TypeError( 161 throw new TypeError(
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
index 2e64269..3118efc 100644
--- a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
+++ b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
@@ -18,7 +18,6 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { BrowserViewBounds } from '@sophie/shared';
22import { BrowserView } from 'electron'; 21import { BrowserView } from 'electron';
23 22
24import type Service from '../../../stores/Service'; 23import type Service from '../../../stores/Service';
@@ -39,7 +38,7 @@ export default class ElectronServiceView implements ServiceView {
39 readonly browserView: BrowserView; 38 readonly browserView: BrowserView;
40 39
41 constructor( 40 constructor(
42 service: Service, 41 private readonly service: Service,
43 resources: Resources, 42 resources: Resources,
44 partition: ElectronPartition, 43 partition: ElectronPartition,
45 private readonly parent: ElectronViewFactory, 44 private readonly parent: ElectronViewFactory,
@@ -56,6 +55,13 @@ export default class ElectronServiceView implements ServiceView {
56 }); 55 });
57 56
58 this.browserView.setBackgroundColor('#fff'); 57 this.browserView.setBackgroundColor('#fff');
58 this.browserView.setAutoResize({
59 width: true,
60 height: true,
61 });
62 // Util we first attach `browserView` to a `BrowserWindow`,
63 // `setBounds` calls will be ignored, so there's no point in callind `updateBounds` here.
64 // It will be called by `ElectronMainWindow` when we first attach this service to it.
59 65
60 const { webContents } = this.browserView; 66 const { webContents } = this.browserView;
61 67
@@ -191,13 +197,17 @@ export default class ElectronServiceView implements ServiceView {
191 this.browserView.webContents.toggleDevTools(); 197 this.browserView.webContents.toggleDevTools();
192 } 198 }
193 199
194 setBounds(bounds: BrowserViewBounds): void { 200 updateBounds(): void {
195 this.browserView.setBounds(bounds); 201 const { x, y, width, height, hasBounds } = this.service;
202 if (!hasBounds) {
203 return;
204 }
205 this.browserView.setBounds({ x, y, width, height });
196 } 206 }
197 207
198 dispose(): void { 208 dispose(): void {
199 this.parent.unregisterServiceView(this.webContentsId);
200 setImmediate(() => { 209 setImmediate(() => {
210 this.parent.unregisterServiceView(this.webContentsId);
201 // Undocumented electron API, see e.g., https://github.com/electron/electron/issues/29626 211 // Undocumented electron API, see e.g., https://github.com/electron/electron/issues/29626
202 ( 212 (
203 this.browserView.webContents as unknown as { destroy(): void } 213 this.browserView.webContents as unknown as { destroy(): void }
diff --git a/packages/main/src/infrastructure/electron/types.ts b/packages/main/src/infrastructure/electron/types.ts
index 1321048..92ca9ad 100644
--- a/packages/main/src/infrastructure/electron/types.ts
+++ b/packages/main/src/infrastructure/electron/types.ts
@@ -18,8 +18,6 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { BrowserViewBounds } from '@sophie/shared';
22
23import type MainStore from '../../stores/MainStore'; 21import type MainStore from '../../stores/MainStore';
24import type Profile from '../../stores/Profile'; 22import type Profile from '../../stores/Profile';
25import type Service from '../../stores/Service'; 23import type Service from '../../stores/Service';
@@ -67,7 +65,7 @@ export interface ServiceView {
67 65
68 toggleDeveloperTools(): void; 66 toggleDeveloperTools(): void;
69 67
70 setBounds(bounds: BrowserViewBounds): void; 68 updateBounds(): void;
71 69
72 dispose(): void; 70 dispose(): void;
73} 71}
diff --git a/packages/main/src/reactions/loadServices.ts b/packages/main/src/reactions/loadServices.ts
index 4ef6131..f56ac62 100644
--- a/packages/main/src/reactions/loadServices.ts
+++ b/packages/main/src/reactions/loadServices.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 { autorun, reaction } from 'mobx'; 21import { reaction } from 'mobx';
22import { addDisposer } from 'mobx-state-tree'; 22import { addDisposer } from 'mobx-state-tree';
23 23
24import type { 24import type {
@@ -94,7 +94,6 @@ export default function loadServices(
94 throw new Error(`Missing Partition ${profileId}`); 94 throw new Error(`Missing Partition ${profileId}`);
95 } 95 }
96 view = viewFactory.createServiceView(service, partition); 96 view = viewFactory.createServiceView(service, partition);
97 view.setBounds(store.browserViewBounds);
98 servicesToViews.set(serviceId, view); 97 servicesToViews.set(serviceId, view);
99 service.setServiceView(view); 98 service.setServiceView(view);
100 const { urlToLoad } = service; 99 const { urlToLoad } = service;
@@ -133,12 +132,7 @@ export default function loadServices(
133 }, 132 },
134 ); 133 );
135 134
136 const resizeDisposer = autorun(() => {
137 store.visibleService?.serviceView?.setBounds(store.browserViewBounds);
138 });
139
140 addDisposer(store, () => { 135 addDisposer(store, () => {
141 resizeDisposer();
142 disposer(); 136 disposer();
143 store.mainWindow?.setServiceView(undefined); 137 store.mainWindow?.setServiceView(undefined);
144 servicesToViews.forEach((serviceView, serviceId) => { 138 servicesToViews.forEach((serviceView, serviceId) => {
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts
index d717bed..9affbd0 100644
--- a/packages/main/src/stores/MainStore.ts
+++ b/packages/main/src/stores/MainStore.ts
@@ -18,9 +18,9 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import type { Action, BrowserViewBounds } from '@sophie/shared'; 21import type { Action } from '@sophie/shared';
22import type { ResourceKey } from 'i18next'; 22import type { ResourceKey } from 'i18next';
23import { applySnapshot, flow, Instance, types } from 'mobx-state-tree'; 23import { flow, Instance, types } from 'mobx-state-tree';
24 24
25import type I18nStore from '../i18n/I18nStore'; 25import type I18nStore from '../i18n/I18nStore';
26import type { UseTranslationResult } from '../i18n/I18nStore'; 26import type { UseTranslationResult } from '../i18n/I18nStore';
@@ -37,15 +37,6 @@ const log = getLogger('MainStore');
37 37
38const MainStore = types 38const MainStore = types
39 .model('MainStore', { 39 .model('MainStore', {
40 browserViewBounds: types.optional(
41 types.model('BrowserViewBounds', {
42 x: 0,
43 y: 0,
44 width: 0,
45 height: 0,
46 }),
47 {},
48 ),
49 shared: types.optional(SharedStore, {}), 40 shared: types.optional(SharedStore, {}),
50 i18n: types.frozen<I18nStore | undefined>(), 41 i18n: types.frozen<I18nStore | undefined>(),
51 }) 42 })
@@ -83,9 +74,6 @@ const MainStore = types
83 }), 74 }),
84 ) 75 )
85 .actions((self) => ({ 76 .actions((self) => ({
86 setBrowserViewBounds(bounds: BrowserViewBounds): void {
87 applySnapshot(self.browserViewBounds, bounds);
88 },
89 setMainWindow(mainWindow: MainWindow | undefined): void { 77 setMainWindow(mainWindow: MainWindow | undefined): void {
90 self.mainWindow = mainWindow; 78 self.mainWindow = mainWindow;
91 }, 79 },
@@ -121,9 +109,6 @@ const MainStore = types
121 .actions((self) => ({ 109 .actions((self) => ({
122 dispatch(action: Action): void { 110 dispatch(action: Action): void {
123 switch (action.action) { 111 switch (action.action) {
124 case 'set-browser-view-bounds':
125 self.setBrowserViewBounds(action.browserViewBounds);
126 break;
127 case 'set-selected-service-id': 112 case 'set-selected-service-id':
128 self.settings.setSelectedServiceId(action.serviceId); 113 self.settings.setSelectedServiceId(action.serviceId);
129 break; 114 break;
diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts
index 1d46dc9..8ba8098 100644
--- a/packages/main/src/stores/Service.ts
+++ b/packages/main/src/stores/Service.ts
@@ -25,6 +25,7 @@ import {
25 defineServiceModel, 25 defineServiceModel,
26 ServiceAction, 26 ServiceAction,
27 type ServiceStateSnapshotIn, 27 type ServiceStateSnapshotIn,
28 type BrowserViewBounds,
28} from '@sophie/shared'; 29} from '@sophie/shared';
29import { type Instance, getSnapshot, cast, flow } from 'mobx-state-tree'; 30import { type Instance, getSnapshot, cast, flow } from 'mobx-state-tree';
30 31
@@ -41,8 +42,18 @@ const Service = defineServiceModel(ServiceSettings)
41 .volatile( 42 .volatile(
42 (): { 43 (): {
43 serviceView: ServiceView | undefined; 44 serviceView: ServiceView | undefined;
45 x: number;
46 y: number;
47 width: number;
48 height: number;
49 hasBounds: boolean;
44 } => ({ 50 } => ({
45 serviceView: undefined, 51 serviceView: undefined,
52 x: 0,
53 y: 0,
54 width: 0,
55 height: 0,
56 hasBounds: false,
46 }), 57 }),
47 ) 58 )
48 .views((self) => ({ 59 .views((self) => ({
@@ -59,10 +70,20 @@ const Service = defineServiceModel(ServiceSettings)
59 })) 70 }))
60 .views((self) => ({ 71 .views((self) => ({
61 get shouldBeVisible(): boolean { 72 get shouldBeVisible(): boolean {
62 return self.shouldBeLoaded && !self.hasError; 73 // Do not attach service views for which we don't know the appropriate frame size,
74 // because they will just appear in a random location until the frame size is determined.
75 return self.shouldBeLoaded && !self.hasError && self.hasBounds;
63 }, 76 },
64 })) 77 }))
65 .actions((self) => ({ 78 .actions((self) => ({
79 setBrowserViewBounds(bounds: BrowserViewBounds): void {
80 self.x = bounds.x;
81 self.y = bounds.y;
82 self.width = bounds.width;
83 self.height = bounds.height;
84 self.hasBounds = true;
85 self.serviceView?.updateBounds();
86 },
66 setServiceView(serviceView: ServiceView | undefined): void { 87 setServiceView(serviceView: ServiceView | undefined): void {
67 self.serviceView = serviceView; 88 self.serviceView = serviceView;
68 }, 89 },
@@ -263,6 +284,9 @@ const Service = defineServiceModel(ServiceSettings)
263 .actions((self) => ({ 284 .actions((self) => ({
264 dispatch(action: ServiceAction): void { 285 dispatch(action: ServiceAction): void {
265 switch (action.action) { 286 switch (action.action) {
287 case 'set-browser-view-bounds':
288 self.setBrowserViewBounds(action.browserViewBounds);
289 break;
266 case 'back': 290 case 'back':
267 self.goBack(); 291 self.goBack();
268 break; 292 break;
diff --git a/packages/renderer/src/components/App.tsx b/packages/renderer/src/components/App.tsx
index d381abf..2f728e7 100644
--- a/packages/renderer/src/components/App.tsx
+++ b/packages/renderer/src/components/App.tsx
@@ -23,12 +23,8 @@ import { observer } from 'mobx-react-lite';
23import React, { useCallback, useEffect } from 'react'; 23import React, { useCallback, useEffect } from 'react';
24import { useTranslation } from 'react-i18next'; 24import { useTranslation } from 'react-i18next';
25 25
26import BrowserViewPlaceholder from './BrowserViewPlaceholder'; 26import ServicePanel from './ServicePanel';
27import { useStore } from './StoreProvider'; 27import { useStore } from './StoreProvider';
28import InsecureConnectionBanner from './banner/InsecureConnectionBanner';
29import NewWindowBanner from './banner/NewWindowBanner';
30import ErrorPage from './errorPage/ErrorPage';
31import LocationBar from './locationBar/LocationBar';
32import Sidebar from './sidebar/Sidebar'; 28import Sidebar from './sidebar/Sidebar';
33 29
34function App({ devMode }: { devMode: boolean }): JSX.Element { 30function App({ devMode }: { devMode: boolean }): JSX.Element {
@@ -37,6 +33,7 @@ function App({ devMode }: { devMode: boolean }): JSX.Element {
37 }); 33 });
38 const { 34 const {
39 settings: { selectedService }, 35 settings: { selectedService },
36 shared: { services },
40 } = useStore(); 37 } = useStore();
41 const { 38 const {
42 settings: { name: serviceName }, 39 settings: { name: serviceName },
@@ -95,20 +92,13 @@ function App({ devMode }: { devMode: boolean }): JSX.Element {
95 <Box 92 <Box
96 sx={{ 93 sx={{
97 flex: 1, 94 flex: 1,
98 display: 'flex',
99 overflow: 'hidden',
100 flexDirection: 'column',
101 alignItems: 'stretch',
102 height: '100%', 95 height: '100%',
103 zIndex: 100, 96 position: 'relative',
104 }} 97 }}
105 > 98 >
106 <LocationBar /> 99 {services.map((service) => (
107 <InsecureConnectionBanner service={selectedService} /> 100 <ServicePanel key={service.id} service={service} />
108 <NewWindowBanner service={selectedService} /> 101 ))}
109 <BrowserViewPlaceholder>
110 <ErrorPage service={selectedService} />
111 </BrowserViewPlaceholder>
112 </Box> 102 </Box>
113 </Box> 103 </Box>
114 ); 104 );
diff --git a/packages/renderer/src/components/BrowserViewPlaceholder.tsx b/packages/renderer/src/components/BrowserViewPlaceholder.tsx
index 9bd1176..2bfc9b0 100644
--- a/packages/renderer/src/components/BrowserViewPlaceholder.tsx
+++ b/packages/renderer/src/components/BrowserViewPlaceholder.tsx
@@ -22,29 +22,29 @@ import Box from '@mui/material/Box';
22import throttle from 'lodash-es/throttle'; 22import throttle from 'lodash-es/throttle';
23import React, { ReactNode, useCallback, useRef } from 'react'; 23import React, { ReactNode, useCallback, useRef } from 'react';
24 24
25import { useStore } from './StoreProvider'; 25import Service from '../stores/Service';
26 26
27function BrowserViewPlaceholder({ 27function BrowserViewPlaceholder({
28 service,
28 children, 29 children,
29}: { 30}: {
31 service: Service;
30 children?: ReactNode; 32 children?: ReactNode;
31}): JSX.Element { 33}): JSX.Element {
32 const store = useStore();
33
34 // eslint-disable-next-line react-hooks/exhaustive-deps -- react-hooks doesn't support `throttle`. 34 // eslint-disable-next-line react-hooks/exhaustive-deps -- react-hooks doesn't support `throttle`.
35 const onResize = useCallback( 35 const onResize = useCallback(
36 throttle(([entry]: ResizeObserverEntry[]) => { 36 throttle(([entry]: ResizeObserverEntry[]) => {
37 if (entry) { 37 if (entry) {
38 const { x, y, width, height } = entry.target.getBoundingClientRect(); 38 const { x, y, width, height } = entry.target.getBoundingClientRect();
39 store.setBrowserViewBounds({ 39 service.setBrowserViewBounds({
40 x: Math.round(x), 40 x: Math.round(x),
41 y: Math.round(y), 41 y: Math.round(y),
42 width: Math.round(width), 42 width: Math.round(width),
43 height: Math.round(height), 43 height: Math.round(height),
44 }); 44 });
45 } 45 }
46 }, 40), 46 }, 100),
47 [store], 47 [service],
48 ); 48 );
49 49
50 const resizeObserverRef = useRef<ResizeObserver | undefined>(); 50 const resizeObserverRef = useRef<ResizeObserver | undefined>();
@@ -61,7 +61,7 @@ function BrowserViewPlaceholder({
61 resizeObserverRef.current = new ResizeObserver(onResize); 61 resizeObserverRef.current = new ResizeObserver(onResize);
62 resizeObserverRef.current.observe(element); 62 resizeObserverRef.current.observe(element);
63 }, 63 },
64 [onResize, resizeObserverRef], 64 [onResize],
65 ); 65 );
66 66
67 return ( 67 return (
diff --git a/packages/renderer/src/components/ServicePanel.tsx b/packages/renderer/src/components/ServicePanel.tsx
new file mode 100644
index 0000000..de58d24
--- /dev/null
+++ b/packages/renderer/src/components/ServicePanel.tsx
@@ -0,0 +1,76 @@
1/*
2 * Copyright (C) 2021-2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import Box from '@mui/material/Box';
22import { observer } from 'mobx-react-lite';
23import React from 'react';
24
25import Service from '../stores/Service';
26
27import BrowserViewPlaceholder from './BrowserViewPlaceholder';
28import { useStore } from './StoreProvider';
29import InsecureConnectionBanner from './banner/InsecureConnectionBanner';
30import NewWindowBanner from './banner/NewWindowBanner';
31import ErrorPage from './errorPage/ErrorPage';
32import LocationBar from './locationBar/LocationBar';
33
34export function getServicePanelID(service: Service): string {
35 return `Sophie-${service.id}-ServicePanel`;
36}
37
38function ServicePanel({ service }: { service: Service }): JSX.Element {
39 const {
40 settings: { selectedService },
41 } = useStore();
42
43 const {
44 settings: { name },
45 } = service;
46 const visible = service === selectedService;
47
48 return (
49 <Box
50 id={getServicePanelID(service)}
51 role="tabpanel"
52 aria-label={name}
53 sx={{
54 position: 'absolute',
55 top: 0,
56 left: 0,
57 bottom: 0,
58 right: 0,
59 display: 'flex',
60 overflow: 'hidden',
61 visibility: visible ? 'visible' : 'hidden',
62 flexDirection: 'column',
63 alignItems: 'stretch',
64 }}
65 >
66 <LocationBar service={service} />
67 <InsecureConnectionBanner service={service} />
68 <NewWindowBanner service={service} />
69 <BrowserViewPlaceholder service={service}>
70 <ErrorPage service={service} />
71 </BrowserViewPlaceholder>
72 </Box>
73 );
74}
75
76export default observer(ServicePanel);
diff --git a/packages/renderer/src/components/banner/InsecureConnectionBanner.tsx b/packages/renderer/src/components/banner/InsecureConnectionBanner.tsx
index 7a03fce..0b70db6 100644
--- a/packages/renderer/src/components/banner/InsecureConnectionBanner.tsx
+++ b/packages/renderer/src/components/banner/InsecureConnectionBanner.tsx
@@ -34,17 +34,12 @@ import NotificationBanner from './NotificationBanner';
34function InsecureConnectionBanner({ 34function InsecureConnectionBanner({
35 service, 35 service,
36}: { 36}: {
37 service: Service | undefined; 37 service: Service;
38}): JSX.Element | null { 38}): JSX.Element | null {
39 const { t } = useTranslation(undefined, { 39 const { t } = useTranslation(undefined, {
40 keyPrefix: 'banner.insecureConnection', 40 keyPrefix: 'banner.insecureConnection',
41 }); 41 });
42 42
43 if (service === undefined) {
44 // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering.
45 return null;
46 }
47
48 const { 43 const {
49 canReconnectSecurely, 44 canReconnectSecurely,
50 hasError, 45 hasError,
diff --git a/packages/renderer/src/components/banner/NewWindowBanner.tsx b/packages/renderer/src/components/banner/NewWindowBanner.tsx
index 478de8e..07fafda 100644
--- a/packages/renderer/src/components/banner/NewWindowBanner.tsx
+++ b/packages/renderer/src/components/banner/NewWindowBanner.tsx
@@ -32,17 +32,12 @@ import NotificationBanner from './NotificationBanner';
32function NewWindowBanner({ 32function NewWindowBanner({
33 service, 33 service,
34}: { 34}: {
35 service: Service | undefined; 35 service: Service;
36}): JSX.Element | null { 36}): JSX.Element | null {
37 const { t } = useTranslation(undefined, { 37 const { t } = useTranslation(undefined, {
38 keyPrefix: 'banner.newWindow', 38 keyPrefix: 'banner.newWindow',
39 }); 39 });
40 40
41 if (service === undefined) {
42 // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering.
43 return null;
44 }
45
46 const { 41 const {
47 popups, 42 popups,
48 settings: { name }, 43 settings: { name },
diff --git a/packages/renderer/src/components/errorPage/ErrorPage.tsx b/packages/renderer/src/components/errorPage/ErrorPage.tsx
index 99ca020..10f54cd 100644
--- a/packages/renderer/src/components/errorPage/ErrorPage.tsx
+++ b/packages/renderer/src/components/errorPage/ErrorPage.tsx
@@ -123,14 +123,10 @@ function formatError(service: Service, t: TFunction): ErrorDetails {
123 } 123 }
124} 124}
125 125
126function ErrorPage({ 126function ErrorPage({ service }: { service: Service }): JSX.Element | null {
127 service,
128}: {
129 service: Service | undefined;
130}): JSX.Element | null {
131 const { t } = useTranslation(undefined); 127 const { t } = useTranslation(undefined);
132 128
133 if (service === undefined || !service.hasError) { 129 if (!service.hasError) {
134 // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering. 130 // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering.
135 return null; 131 return null;
136 } 132 }
diff --git a/packages/renderer/src/components/locationBar/ExtraButtons.tsx b/packages/renderer/src/components/locationBar/ExtraButtons.tsx
index ef90199..4d4c3c4 100644
--- a/packages/renderer/src/components/locationBar/ExtraButtons.tsx
+++ b/packages/renderer/src/components/locationBar/ExtraButtons.tsx
@@ -27,11 +27,7 @@ import { useTranslation } from 'react-i18next';
27 27
28import type Service from '../../stores/Service'; 28import type Service from '../../stores/Service';
29 29
30function ExtraButtons({ 30function ExtraButtons({ service }: { service: Service }): JSX.Element {
31 service,
32}: {
33 service: Service | undefined;
34}): JSX.Element {
35 const { t } = useTranslation(undefined, { 31 const { t } = useTranslation(undefined, {
36 keyPrefix: 'toolbar', 32 keyPrefix: 'toolbar',
37 }); 33 });
@@ -40,8 +36,8 @@ function ExtraButtons({
40 <Box display="flex"> 36 <Box display="flex">
41 <IconButton 37 <IconButton
42 aria-label={t('openInBrowser')} 38 aria-label={t('openInBrowser')}
43 disabled={service?.currentUrl === undefined} 39 disabled={service.currentUrl === undefined}
44 onClick={() => service?.openCurrentURLInExternalBrowser()} 40 onClick={() => service.openCurrentURLInExternalBrowser()}
45 > 41 >
46 <IconOpenInBrowser /> 42 <IconOpenInBrowser />
47 </IconButton> 43 </IconButton>
diff --git a/packages/renderer/src/components/locationBar/LocationBar.tsx b/packages/renderer/src/components/locationBar/LocationBar.tsx
index 54ead8e..c290722 100644
--- a/packages/renderer/src/components/locationBar/LocationBar.tsx
+++ b/packages/renderer/src/components/locationBar/LocationBar.tsx
@@ -22,13 +22,16 @@ import { styled } from '@mui/material/styles';
22import { observer } from 'mobx-react-lite'; 22import { observer } from 'mobx-react-lite';
23import React from 'react'; 23import React from 'react';
24 24
25import type Service from '../../stores/Service';
25import { useStore } from '../StoreProvider'; 26import { useStore } from '../StoreProvider';
26 27
27import ExtraButtons from './ExtraButtons'; 28import ExtraButtons from './ExtraButtons';
28import LocationTextField from './LocationTextField'; 29import LocationTextField from './LocationTextField';
29import NavigationButtons from './NavigationButtons'; 30import NavigationButtons from './NavigationButtons';
30 31
31export const LOCATION_BAR_ID = 'Sophie-LocationBar'; 32export function getLocaltionBarID(service: Service): string {
33 return `Sophie-${service.id}-LocationBar`;
34}
32 35
33const LocationBarRoot = styled('header', { 36const LocationBarRoot = styled('header', {
34 name: 'LocationBar', 37 name: 'LocationBar',
@@ -41,17 +44,22 @@ const LocationBarRoot = styled('header', {
41 borderBottom: `1px solid ${theme.palette.divider}`, 44 borderBottom: `1px solid ${theme.palette.divider}`,
42})); 45}));
43 46
44function LocationBar(): JSX.Element { 47function LocationBar({ service }: { service: Service }): JSX.Element {
45 const { 48 const {
46 shared: { locationBarVisible }, 49 settings: { showLocationBar },
47 settings: { selectedService },
48 } = useStore(); 50 } = useStore();
49 51
52 const { alwaysShowLocationBar } = service;
53 const locationBarVisible = showLocationBar || alwaysShowLocationBar;
54
50 return ( 55 return (
51 <LocationBarRoot id={LOCATION_BAR_ID} hidden={!locationBarVisible}> 56 <LocationBarRoot
52 <NavigationButtons service={selectedService} /> 57 id={getLocaltionBarID(service)}
53 <LocationTextField service={selectedService} /> 58 hidden={!locationBarVisible}
54 <ExtraButtons service={selectedService} /> 59 >
60 <NavigationButtons service={service} />
61 <LocationTextField service={service} />
62 <ExtraButtons service={service} />
55 </LocationBarRoot> 63 </LocationBarRoot>
56 ); 64 );
57} 65}
diff --git a/packages/renderer/src/components/locationBar/LocationTextField.tsx b/packages/renderer/src/components/locationBar/LocationTextField.tsx
index 85cf794..1d6b561 100644
--- a/packages/renderer/src/components/locationBar/LocationTextField.tsx
+++ b/packages/renderer/src/components/locationBar/LocationTextField.tsx
@@ -20,7 +20,6 @@
20 20
21import FilledInput from '@mui/material/FilledInput'; 21import FilledInput from '@mui/material/FilledInput';
22import { styled } from '@mui/material/styles'; 22import { styled } from '@mui/material/styles';
23import { SecurityLabelKind } from '@sophie/shared';
24import { autorun } from 'mobx'; 23import { autorun } from 'mobx';
25import { observer } from 'mobx-react-lite'; 24import { observer } from 'mobx-react-lite';
26import React, { useCallback, useEffect, useState } from 'react'; 25import React, { useCallback, useEffect, useState } from 'react';
@@ -44,19 +43,15 @@ const LocationTextFieldRoot = styled(FilledInput, {
44 }, 43 },
45})); 44}));
46 45
47function LocationTextField({ 46function LocationTextField({ service }: { service: Service }): JSX.Element {
48 service,
49}: {
50 service: Service | undefined;
51}): JSX.Element {
52 const [inputFocused, setInputFocused] = useState(false); 47 const [inputFocused, setInputFocused] = useState(false);
53 const [changed, setChanged] = useState(false); 48 const [changed, setChanged] = useState(false);
54 const [value, setValue] = useState(''); 49 const [value, setValue] = useState('');
55 50
56 const resetValue = useCallback(() => { 51 const resetValue = useCallback(() => {
57 setValue(service?.currentUrl ?? ''); 52 setValue(service.currentUrl ?? '');
58 setChanged(false); 53 setChanged(false);
59 }, [service, setChanged, setValue]); 54 }, [service]);
60 55
61 useEffect( 56 useEffect(
62 () => 57 () =>
@@ -84,8 +79,8 @@ function LocationTextField({
84 overlayVisible: !inputFocused && !changed, 79 overlayVisible: !inputFocused && !changed,
85 overlay: ( 80 overlay: (
86 <UrlOverlay 81 <UrlOverlay
87 url={service?.currentUrl ?? ''} 82 url={service.currentUrl ?? ''}
88 alert={service?.hasSecurityLabelWarning ?? false} 83 alert={service.hasSecurityLabelWarning ?? false}
89 /> 84 />
90 ), 85 ),
91 }} 86 }}
@@ -102,7 +97,7 @@ function LocationTextField({
102 resetValue(); 97 resetValue();
103 break; 98 break;
104 case 'Enter': 99 case 'Enter':
105 service?.go(value); 100 service.go(value);
106 break; 101 break;
107 default: 102 default:
108 // Nothing to do, let the key event through. 103 // Nothing to do, let the key event through.
@@ -117,14 +112,14 @@ function LocationTextField({
117 disableUnderline 112 disableUnderline
118 startAdornment={ 113 startAdornment={
119 <SecurityLabel 114 <SecurityLabel
120 kind={service?.securityLabel ?? SecurityLabelKind.Empty} 115 kind={service.securityLabel}
121 changed={changed} 116 changed={changed}
122 position="start" 117 position="start"
123 /> 118 />
124 } 119 }
125 endAdornment={ 120 endAdornment={
126 changed ? ( 121 changed ? (
127 <GoButton onClick={() => service?.go(value)} position="end" /> 122 <GoButton onClick={() => service.go(value)} position="end" />
128 ) : undefined 123 ) : undefined
129 } 124 }
130 value={value} 125 value={value}
diff --git a/packages/renderer/src/components/locationBar/NavigationButtons.tsx b/packages/renderer/src/components/locationBar/NavigationButtons.tsx
index 9c4ebdb..96e40e7 100644
--- a/packages/renderer/src/components/locationBar/NavigationButtons.tsx
+++ b/packages/renderer/src/components/locationBar/NavigationButtons.tsx
@@ -32,11 +32,7 @@ import { useTranslation } from 'react-i18next';
32 32
33import type Service from '../../stores/Service'; 33import type Service from '../../stores/Service';
34 34
35function NavigationButtons({ 35function NavigationButtons({ service }: { service: Service }): JSX.Element {
36 service,
37}: {
38 service: Service | undefined;
39}): JSX.Element {
40 const { t } = useTranslation(undefined, { 36 const { t } = useTranslation(undefined, {
41 keyPrefix: 'toolbar', 37 keyPrefix: 'toolbar',
42 }); 38 });
@@ -46,36 +42,31 @@ function NavigationButtons({
46 <Box display="flex"> 42 <Box display="flex">
47 <IconButton 43 <IconButton
48 aria-label={t('back')} 44 aria-label={t('back')}
49 disabled={service === undefined || !service.canGoBack} 45 disabled={!service.canGoBack}
50 onClick={() => service?.goBack()} 46 onClick={() => service.goBack()}
51 > 47 >
52 {direction === 'ltr' ? <IconArrowBack /> : <IconArrowForward />} 48 {direction === 'ltr' ? <IconArrowBack /> : <IconArrowForward />}
53 </IconButton> 49 </IconButton>
54 <IconButton 50 <IconButton
55 aria-label={t('forward')} 51 aria-label={t('forward')}
56 disabled={service === undefined || !service.canGoForward} 52 disabled={!service.canGoForward}
57 onClick={() => service?.goForward()} 53 onClick={() => service.goForward()}
58 > 54 >
59 {direction === 'ltr' ? <IconArrowForward /> : <IconArrowBack />} 55 {direction === 'ltr' ? <IconArrowForward /> : <IconArrowBack />}
60 </IconButton> 56 </IconButton>
61 {service?.loading ?? false ? ( 57 {service.loading ? (
62 <IconButton aria-label={t('stop')} onClick={() => service?.stop()}> 58 <IconButton aria-label={t('stop')} onClick={() => service.stop()}>
63 <IconStop /> 59 <IconStop />
64 </IconButton> 60 </IconButton>
65 ) : ( 61 ) : (
66 <IconButton 62 <IconButton
67 aria-label={t('reload')} 63 aria-label={t('reload')}
68 disabled={service === undefined} 64 onClick={(event) => service.reload(event.shiftKey)}
69 onClick={(event) => service?.reload(event.shiftKey)}
70 > 65 >
71 <IconRefresh /> 66 <IconRefresh />
72 </IconButton> 67 </IconButton>
73 )} 68 )}
74 <IconButton 69 <IconButton aria-label={t('home')} onClick={() => service.goHome()}>
75 aria-label={t('home')}
76 disabled={service === undefined}
77 onClick={() => service?.goHome()}
78 >
79 <IconHome /> 70 <IconHome />
80 </IconButton> 71 </IconButton>
81 </Box> 72 </Box>
diff --git a/packages/renderer/src/components/sidebar/ServiceSwitcher.tsx b/packages/renderer/src/components/sidebar/ServiceSwitcher.tsx
index 0ebd359..24cfd0c 100644
--- a/packages/renderer/src/components/sidebar/ServiceSwitcher.tsx
+++ b/packages/renderer/src/components/sidebar/ServiceSwitcher.tsx
@@ -27,6 +27,7 @@ import React from 'react';
27import { useTranslation } from 'react-i18next'; 27import { useTranslation } from 'react-i18next';
28 28
29import type Service from '../../stores/Service'; 29import type Service from '../../stores/Service';
30import { getServicePanelID } from '../ServicePanel';
30import { useStore } from '../StoreProvider'; 31import { useStore } from '../StoreProvider';
31 32
32import ServiceIcon from './ServiceIcon'; 33import ServiceIcon from './ServiceIcon';
@@ -112,6 +113,7 @@ function ServiceSwitcher(): JSX.Element {
112 value={service.id} 113 value={service.id}
113 icon={<ServiceIcon service={service} />} 114 icon={<ServiceIcon service={service} />}
114 aria-label={getServiceTitle(service, t)} 115 aria-label={getServiceTitle(service, t)}
116 aria-controls={getServicePanelID(service)}
115 /> 117 />
116 ))} 118 ))}
117 </ServiceSwitcherRoot> 119 </ServiceSwitcherRoot>
diff --git a/packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx b/packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx
index 7fc559d..c697170 100644
--- a/packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx
+++ b/packages/renderer/src/components/sidebar/ToggleLocationBarButton.tsx
@@ -28,7 +28,7 @@ import React from 'react';
28import { useTranslation } from 'react-i18next'; 28import { useTranslation } from 'react-i18next';
29 29
30import { useStore } from '../StoreProvider'; 30import { useStore } from '../StoreProvider';
31import { LOCATION_BAR_ID } from '../locationBar/LocationBar'; 31import { getLocaltionBarID } from '../locationBar/LocationBar';
32 32
33function ToggleLocationBarIcon({ 33function ToggleLocationBarIcon({
34 loading, 34 loading,
@@ -54,13 +54,17 @@ function ToggleLocationBarButton(): JSX.Element {
54 const { selectedService } = settings; 54 const { selectedService } = settings;
55 55
56 return ( 56 return (
57 /* eslint-disable react/jsx-props-no-spreading -- Conditionally set the aria-controls prop. */
57 <IconButton 58 <IconButton
58 disabled={!canToggleLocationBar} 59 disabled={!canToggleLocationBar}
59 aria-pressed={locationBarVisible} 60 aria-pressed={locationBarVisible}
60 aria-controls={LOCATION_BAR_ID} 61 {...(selectedService === undefined
62 ? {}
63 : { 'aria-controls': getLocaltionBarID(selectedService) })}
61 aria-label={t('toolbar.toggleLocationBar')} 64 aria-label={t('toolbar.toggleLocationBar')}
62 onClick={() => settings.toggleLocationBar()} 65 onClick={() => settings.toggleLocationBar()}
63 > 66 >
67 {/* eslint-enable react/jsx-props-no-spreading */}
64 <ToggleLocationBarIcon 68 <ToggleLocationBarIcon
65 loading={selectedService?.loading ?? false} 69 loading={selectedService?.loading ?? false}
66 show={locationBarVisible} 70 show={locationBarVisible}
diff --git a/packages/renderer/src/stores/RendererStore.ts b/packages/renderer/src/stores/RendererStore.ts
index 7052162..a3983ca 100644
--- a/packages/renderer/src/stores/RendererStore.ts
+++ b/packages/renderer/src/stores/RendererStore.ts
@@ -18,14 +18,14 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { BrowserViewBounds, SophieRenderer } from '@sophie/shared'; 21import type { SophieRenderer } from '@sophie/shared';
22import { applySnapshot, applyPatch, Instance, types } from 'mobx-state-tree'; 22import { applySnapshot, applyPatch, Instance, types } from 'mobx-state-tree';
23 23
24import { getLogger } from '../utils/log'; 24import { getLogger } from '../utils/log';
25 25
26import GlobalSettings from './GlobalSettings'; 26import type GlobalSettings from './GlobalSettings';
27import RendererEnv, { getEnv } from './RendererEnv'; 27import type RendererEnv from './RendererEnv';
28import Service from './Service'; 28import type Service from './Service';
29import SharedStore from './SharedStore'; 29import SharedStore from './SharedStore';
30 30
31const log = getLogger('RendererStore'); 31const log = getLogger('RendererStore');
@@ -41,14 +41,6 @@ const RendererStore = types
41 get services(): Service[] { 41 get services(): Service[] {
42 return self.shared.services; 42 return self.shared.services;
43 }, 43 },
44 }))
45 .actions((self) => ({
46 setBrowserViewBounds(browserViewBounds: BrowserViewBounds): void {
47 getEnv(self).dispatchMainAction({
48 action: 'set-browser-view-bounds',
49 browserViewBounds,
50 });
51 },
52 })); 44 }));
53 45
54/* 46/*
diff --git a/packages/renderer/src/stores/Service.ts b/packages/renderer/src/stores/Service.ts
index dcaf96e..c8d513f 100644
--- a/packages/renderer/src/stores/Service.ts
+++ b/packages/renderer/src/stores/Service.ts
@@ -18,7 +18,11 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { defineServiceModel, ServiceAction } from '@sophie/shared'; 21import {
22 BrowserViewBounds,
23 defineServiceModel,
24 ServiceAction,
25} from '@sophie/shared';
22import { Instance } from 'mobx-state-tree'; 26import { Instance } from 'mobx-state-tree';
23 27
24import { getEnv } from './RendererEnv'; 28import { getEnv } from './RendererEnv';
@@ -49,6 +53,12 @@ const Service = defineServiceModel(ServiceSettings)
49 } 53 }
50 54
51 return { 55 return {
56 setBrowserViewBounds(browserViewBounds: BrowserViewBounds): void {
57 dispatch({
58 action: 'set-browser-view-bounds',
59 browserViewBounds,
60 });
61 },
52 goBack(): void { 62 goBack(): void {
53 dispatch({ 63 dispatch({
54 action: 'back', 64 action: 'back',
diff --git a/packages/shared/src/schemas/Action.ts b/packages/shared/src/schemas/Action.ts
index ce983fa..7ece27a 100644
--- a/packages/shared/src/schemas/Action.ts
+++ b/packages/shared/src/schemas/Action.ts
@@ -20,7 +20,6 @@
20 20
21import { z } from 'zod'; 21import { z } from 'zod';
22 22
23import { BrowserViewBounds } from './BrowserViewBounds';
24import { ServiceAction } from './ServiceAction'; 23import { ServiceAction } from './ServiceAction';
25import { ThemeSource } from './ThemeSource'; 24import { ThemeSource } from './ThemeSource';
26 25
@@ -31,10 +30,6 @@ export const Action = /* @__PURE__ */ (() =>
31 serviceId: z.string(), 30 serviceId: z.string(),
32 }), 31 }),
33 z.object({ 32 z.object({
34 action: z.literal('set-browser-view-bounds'),
35 browserViewBounds: BrowserViewBounds,
36 }),
37 z.object({
38 action: z.literal('set-theme-source'), 33 action: z.literal('set-theme-source'),
39 themeSource: ThemeSource, 34 themeSource: ThemeSource,
40 }), 35 }),
diff --git a/packages/shared/src/schemas/ServiceAction.ts b/packages/shared/src/schemas/ServiceAction.ts
index c0f07d6..9486aaf 100644
--- a/packages/shared/src/schemas/ServiceAction.ts
+++ b/packages/shared/src/schemas/ServiceAction.ts
@@ -20,9 +20,15 @@
20 20
21import { z } from 'zod'; 21import { z } from 'zod';
22 22
23import { BrowserViewBounds } from './BrowserViewBounds';
24
23export const ServiceAction = /* @__PURE__ */ (() => 25export const ServiceAction = /* @__PURE__ */ (() =>
24 z.union([ 26 z.union([
25 z.object({ 27 z.object({
28 action: z.literal('set-browser-view-bounds'),
29 browserViewBounds: BrowserViewBounds,
30 }),
31 z.object({
26 action: z.literal('back'), 32 action: z.literal('back'),
27 }), 33 }),
28 z.object({ 34 z.object({
diff --git a/packages/shared/src/stores/SharedStoreBase.ts b/packages/shared/src/stores/SharedStoreBase.ts
index e4b3a38..949ef6a 100644
--- a/packages/shared/src/stores/SharedStoreBase.ts
+++ b/packages/shared/src/stores/SharedStoreBase.ts
@@ -65,7 +65,10 @@ export function defineSharedStoreModel<
65 return settings.showLocationBar || self.alwaysShowLocationBar; 65 return settings.showLocationBar || self.alwaysShowLocationBar;
66 }, 66 },
67 get canToggleLocationBar(): boolean { 67 get canToggleLocationBar(): boolean {
68 return !self.alwaysShowLocationBar; 68 return (
69 !self.alwaysShowLocationBar &&
70 self.settings.selectedService !== undefined
71 );
69 }, 72 },
70 })); 73 }));
71} 74}