aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-03-28 23:37:15 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-05-16 00:54:56 +0200
commit04555cc62c9cded08c3090288fa372d961c50737 (patch)
tree4566893892216446dfe24490c98881316b97cb41
parentdesign: Increase location bar UI density (diff)
downloadsophie-04555cc62c9cded08c3090288fa372d961c50737.tar.gz
sophie-04555cc62c9cded08c3090288fa372d961c50737.tar.zst
sophie-04555cc62c9cded08c3090288fa372d961c50737.zip
feat: New window banner
* Add renderer code for notification banners with buttons * Handle new window open requests by denying them and displaying a notification Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts10
-rw-r--r--packages/main/src/stores/Service.ts51
-rw-r--r--packages/renderer/src/components/App.tsx2
-rw-r--r--packages/renderer/src/components/NewWindowBanner.tsx110
-rw-r--r--packages/renderer/src/components/NotificationBanner.tsx99
-rw-r--r--packages/renderer/src/stores/Service.ts28
-rw-r--r--packages/shared/src/schemas/ServiceAction.ts18
-rw-r--r--packages/shared/src/stores/ServiceBase.ts1
8 files changed, 319 insertions, 0 deletions
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
index edcf758..089e63a 100644
--- a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
+++ b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
@@ -136,6 +136,16 @@ export default class ElectronServiceView implements ServiceView {
136 const { reason, exitCode } = details; 136 const { reason, exitCode } = details;
137 service.setCrashed(reason, exitCode); 137 service.setCrashed(reason, exitCode);
138 }); 138 });
139
140 webContents.setWindowOpenHandler(({ url }) => {
141 // TODO Add filtering (allowlist) by URL.
142 // TODO Handle `new-window` disposition where the service wants an object returned by
143 // `window.open`.
144 // TODO Handle downloads with `save-to-disk` disposition.
145 // TODO Handle POST bodies where the window must be allowed to open or the data is lost.
146 service.addBlockedPopup(url);
147 return { action: 'deny' };
148 });
139 } 149 }
140 150
141 get webContentsId(): number { 151 get webContentsId(): number {
diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts
index 26e3517..0a35114 100644
--- a/packages/main/src/stores/Service.ts
+++ b/packages/main/src/stores/Service.ts
@@ -105,6 +105,27 @@ const Service = defineServiceModel(ServiceSettings)
105 } 105 }
106 getEnv(self).openURLInExternalBrowser(self.currentUrl); 106 getEnv(self).openURLInExternalBrowser(self.currentUrl);
107 }, 107 },
108 addBlockedPopup(url: string): void {
109 const index = self.popups.indexOf(url);
110 if (index >= 0) {
111 // Move existing popup to the end of the array,
112 // because later popups have precedence over earlier ones.
113 self.popups.splice(index, 1);
114 }
115 self.popups.push(url);
116 },
117 dismissPopup(url: string): boolean {
118 const index = self.popups.indexOf(url);
119 if (index < 0) {
120 log.warn('Service', self.id, 'has no pending popup', url);
121 return false;
122 }
123 self.popups.splice(index, 1);
124 return true;
125 },
126 dismissAllPopups(): void {
127 self.popups.splice(0);
128 },
108 })) 129 }))
109 .actions((self) => { 130 .actions((self) => {
110 function setState(state: ServiceStateSnapshotIn): void { 131 function setState(state: ServiceStateSnapshotIn): void {
@@ -190,6 +211,21 @@ const Service = defineServiceModel(ServiceSettings)
190 self.state.trust = 'accepted'; 211 self.state.trust = 'accepted';
191 self.reload(); 212 self.reload();
192 }, 213 },
214 followPopup(url: string): void {
215 if (self.dismissPopup(url)) {
216 self.go(url);
217 }
218 },
219 openPopupInExternalBrowser(url: string): void {
220 if (self.dismissPopup(url)) {
221 getEnv(self).openURLInExternalBrowser(url);
222 }
223 },
224 openAllPopupsInExternalBrowser(): void {
225 const env = getEnv(self);
226 self.popups.forEach((popup) => env.openURLInExternalBrowser(popup));
227 self.dismissAllPopups();
228 },
193 })) 229 }))
194 .actions((self) => ({ 230 .actions((self) => ({
195 dispatch(action: ServiceAction): void { 231 dispatch(action: ServiceAction): void {
@@ -218,6 +254,21 @@ const Service = defineServiceModel(ServiceSettings)
218 case 'open-current-url-in-external-browser': 254 case 'open-current-url-in-external-browser':
219 self.openCurrentURLInExternalBrowser(); 255 self.openCurrentURLInExternalBrowser();
220 break; 256 break;
257 case 'follow-popup':
258 self.followPopup(action.url);
259 break;
260 case 'open-popup-in-external-browser':
261 self.openPopupInExternalBrowser(action.url);
262 break;
263 case 'open-all-popups-in-external-browser':
264 self.openAllPopupsInExternalBrowser();
265 break;
266 case 'dismiss-popup':
267 self.dismissPopup(action.url);
268 break;
269 case 'dismiss-all-popups':
270 self.dismissAllPopups();
271 break;
221 default: 272 default:
222 log.error('Unknown action to dispatch', action); 273 log.error('Unknown action to dispatch', action);
223 break; 274 break;
diff --git a/packages/renderer/src/components/App.tsx b/packages/renderer/src/components/App.tsx
index b647a80..49c50af 100644
--- a/packages/renderer/src/components/App.tsx
+++ b/packages/renderer/src/components/App.tsx
@@ -24,6 +24,7 @@ import { observer } from 'mobx-react-lite';
24import React, { useCallback } from 'react'; 24import React, { useCallback } from 'react';
25 25
26import BrowserViewPlaceholder from './BrowserViewPlaceholder'; 26import BrowserViewPlaceholder from './BrowserViewPlaceholder';
27import NewWindowBanner from './NewWindowBanner';
27import { useStore } from './StoreProvider'; 28import { useStore } from './StoreProvider';
28import LocationBar from './locationBar/LocationBar'; 29import LocationBar from './locationBar/LocationBar';
29import Sidebar from './sidebar/Sidebar'; 30import Sidebar from './sidebar/Sidebar';
@@ -77,6 +78,7 @@ function App(): JSX.Element {
77 }} 78 }}
78 > 79 >
79 <LocationBar /> 80 <LocationBar />
81 <NewWindowBanner service={selectedService} />
80 <BrowserViewPlaceholder> 82 <BrowserViewPlaceholder>
81 <p>{JSON.stringify(selectedService?.state)}</p> 83 <p>{JSON.stringify(selectedService?.state)}</p>
82 {selectedService?.state.type === 'certificateError' && ( 84 {selectedService?.state.type === 'certificateError' && (
diff --git a/packages/renderer/src/components/NewWindowBanner.tsx b/packages/renderer/src/components/NewWindowBanner.tsx
new file mode 100644
index 0000000..a49b4b1
--- /dev/null
+++ b/packages/renderer/src/components/NewWindowBanner.tsx
@@ -0,0 +1,110 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import IconOpenInBrowser from '@mui/icons-material/OpenInBrowser';
22import IconOpenInNew from '@mui/icons-material/OpenInNew';
23import Button from '@mui/material/Button';
24import { observer } from 'mobx-react-lite';
25import React from 'react';
26
27import type Service from '../stores/Service';
28
29import NotificationBanner from './NotificationBanner';
30
31function NewWindowBanner({
32 service,
33}: {
34 service: Service | undefined;
35}): JSX.Element | null {
36 if (service === undefined) {
37 // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering.
38 return null;
39 }
40
41 const {
42 popups,
43 settings: { name },
44 } = service;
45 const { length: count } = popups;
46
47 if (count === 0) {
48 // eslint-disable-next-line unicorn/no-null -- React requires `null` to skip rendering.
49 return null;
50 }
51
52 const url = popups[count - 1];
53
54 return (
55 <NotificationBanner
56 severity="warning"
57 icon={<IconOpenInNew fontSize="inherit" />}
58 onClose={() => service.dismissAllPopups()}
59 buttons={
60 <>
61 <Button
62 onClick={() => service.followPopup(url)}
63 color="inherit"
64 size="small"
65 >
66 Follow link in this window
67 </Button>
68 <Button
69 onClick={() => service.openPopupInExternalBrowser(url)}
70 color="inherit"
71 size="small"
72 startIcon={<IconOpenInBrowser />}
73 >
74 Open in browser
75 </Button>
76 {count > 1 && (
77 <>
78 <Button
79 onClick={() => service.openAllPopupsInExternalBrowser()}
80 color="inherit"
81 size="small"
82 startIcon={<IconOpenInBrowser />}
83 >
84 Open all
85 </Button>
86 <Button
87 onClick={() => service.dismissPopup(url)}
88 color="inherit"
89 size="small"
90 >
91 Ignore
92 </Button>
93 </>
94 )}
95 </>
96 }
97 >
98 {name} wants to open <b>{url}</b>{' '}
99 {count === 1 ? (
100 <>in a new window</>
101 ) : (
102 <>
103 and <b>{count}</b> other links in new windows
104 </>
105 )}
106 </NotificationBanner>
107 );
108}
109
110export default observer(NewWindowBanner);
diff --git a/packages/renderer/src/components/NotificationBanner.tsx b/packages/renderer/src/components/NotificationBanner.tsx
new file mode 100644
index 0000000..d591e14
--- /dev/null
+++ b/packages/renderer/src/components/NotificationBanner.tsx
@@ -0,0 +1,99 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { Typography } from '@mui/material';
22import Alert, { AlertColor } from '@mui/material/Alert';
23import Box from '@mui/material/Box';
24import { styled } from '@mui/material/styles';
25import React, { ReactNode } from 'react';
26
27const NotificationBannerRoot = styled(Alert)(({ theme }) => ({
28 paddingTop: 7,
29 paddingBottom: 6,
30 // Match the height of the location bar.
31 minHeight: 53,
32 borderRadius: 0,
33 borderBottom: `1px solid ${theme.palette.divider}`,
34 '.MuiAlert-message': {
35 flexGrow: 1,
36 paddingTop: 0,
37 paddingBottom: 4,
38 display: 'flex',
39 flexWrap: 'wrap',
40 justifyContent: 'center',
41 },
42 '.MuiAlert-action': {
43 paddingInlineStart: 0,
44 },
45}));
46
47const NotificationBannerText = styled(Typography)(({ theme }) => ({
48 fontSize: 'inherit',
49 paddingTop: theme.spacing(1),
50 paddingInlineEnd: theme.spacing(2),
51 flexGrow: 9999,
52}));
53
54const NotificationBannerButtons = styled(Box)(({ theme }) => ({
55 fontSize: 'inherit',
56 paddingTop: theme.spacing(0.5),
57 paddingInlineEnd: theme.spacing(0.5),
58 display: 'flex',
59 flexWrap: 'wrap',
60 flexGrow: 1,
61 gap: theme.spacing(1),
62 '.MuiButton-root': {
63 flexGrow: 1,
64 },
65}));
66
67export default function NotificationBanner({
68 severity,
69 icon,
70 onClose,
71 buttons,
72 children,
73}: {
74 severity?: AlertColor;
75 icon?: ReactNode;
76 onClose: () => void;
77 buttons?: ReactNode;
78 children?: ReactNode;
79}): JSX.Element {
80 return (
81 <NotificationBannerRoot
82 severity={severity ?? 'success'}
83 icon={icon ?? false}
84 onClose={onClose}
85 >
86 <NotificationBannerText>{children}</NotificationBannerText>
87 {buttons && (
88 <NotificationBannerButtons>{buttons}</NotificationBannerButtons>
89 )}
90 </NotificationBannerRoot>
91 );
92}
93
94NotificationBanner.defaultProps = {
95 severity: 'success',
96 icon: false,
97 buttons: undefined,
98 children: undefined,
99};
diff --git a/packages/renderer/src/stores/Service.ts b/packages/renderer/src/stores/Service.ts
index c50e5bd..e14d80b 100644
--- a/packages/renderer/src/stores/Service.ts
+++ b/packages/renderer/src/stores/Service.ts
@@ -80,6 +80,34 @@ const Service = defineServiceModel(ServiceSettings).actions((self) => {
80 action: 'open-current-url-in-external-browser', 80 action: 'open-current-url-in-external-browser',
81 }); 81 });
82 }, 82 },
83 followPopup(url: string): void {
84 dispatch({
85 action: 'follow-popup',
86 url,
87 });
88 },
89 openPopupInExternalBrowser(url: string): void {
90 dispatch({
91 action: 'open-popup-in-external-browser',
92 url,
93 });
94 },
95 openAllPopupsInExternalBrowser(): void {
96 dispatch({
97 action: 'open-all-popups-in-external-browser',
98 });
99 },
100 dismissPopup(url: string): void {
101 dispatch({
102 action: 'dismiss-popup',
103 url,
104 });
105 },
106 dismissAllPopups(): void {
107 dispatch({
108 action: 'dismiss-all-popups',
109 });
110 },
83 }; 111 };
84}); 112});
85 113
diff --git a/packages/shared/src/schemas/ServiceAction.ts b/packages/shared/src/schemas/ServiceAction.ts
index da0b96b..cfaba08 100644
--- a/packages/shared/src/schemas/ServiceAction.ts
+++ b/packages/shared/src/schemas/ServiceAction.ts
@@ -49,6 +49,24 @@ export const ServiceAction = /* @__PURE__ */ (() =>
49 z.object({ 49 z.object({
50 action: z.literal('open-current-url-in-external-browser'), 50 action: z.literal('open-current-url-in-external-browser'),
51 }), 51 }),
52 z.object({
53 action: z.literal('follow-popup'),
54 url: z.string(),
55 }),
56 z.object({
57 action: z.literal('open-popup-in-external-browser'),
58 url: z.string(),
59 }),
60 z.object({
61 action: z.literal('open-all-popups-in-external-browser'),
62 }),
63 z.object({
64 action: z.literal('dismiss-popup'),
65 url: z.string(),
66 }),
67 z.object({
68 action: z.literal('dismiss-all-popups'),
69 }),
52 ]))(); 70 ]))();
53 71
54/* 72/*
diff --git a/packages/shared/src/stores/ServiceBase.ts b/packages/shared/src/stores/ServiceBase.ts
index c69f339..a19f59e 100644
--- a/packages/shared/src/stores/ServiceBase.ts
+++ b/packages/shared/src/stores/ServiceBase.ts
@@ -37,6 +37,7 @@ export function defineServiceModel<TS extends IAnyModelType>(settings: TS) {
37 state: ServiceState, 37 state: ServiceState,
38 directMessageCount: 0, 38 directMessageCount: 0,
39 indirectMessageCount: 0, 39 indirectMessageCount: 0,
40 popups: types.array(types.string),
40 }) 41 })
41 .views((self) => ({ 42 .views((self) => ({
42 get loading(): boolean { 43 get loading(): boolean {