From ea1bb7427d29716b109da45d0f7f2df4f62d8be8 Mon Sep 17 00:00:00 2001 From: tim2zg Date: Tue, 17 Mar 2026 15:33:15 +0100 Subject: [PATCH 1/4] refactor: Clean MVVM migration for NetworkProxyModal - Ported modal logic to NetworkProxyViewModel and view to NetworkProxyView in shared-components - Implemented real logic for supportsProxyConfiguration in ElectronPlatform - Renamed open_proxy_settings to openProxySettings for consistent casing - Removed German translations (to be handled via Localazy) - Registered desktopProxyConfig in SettingsStore - Simplified NetworkProxyModal by utilizing the new MVVM components and BaseDialog --- apps/web/src/@types/global.d.ts | 1 + apps/web/src/BasePlatform.ts | 8 + .../views/settings/NetworkProxyModal.tsx | 53 ++++ .../tabs/user/SecurityUserSettingsTab.tsx | 33 +++ .../src/vector/platform/ElectronPlatform.tsx | 4 + packages/shared-components/src/index.ts | 1 + .../NetworkProxyView.module.css | 87 +++++++ .../NetworkProxyView/NetworkProxyView.tsx | 229 ++++++++++++++++++ .../src/settings/NetworkProxyView/index.tsx | 17 ++ 9 files changed, 433 insertions(+) create mode 100644 apps/web/src/components/views/settings/NetworkProxyModal.tsx create mode 100644 packages/shared-components/src/settings/NetworkProxyView/NetworkProxyView.module.css create mode 100644 packages/shared-components/src/settings/NetworkProxyView/NetworkProxyView.tsx create mode 100644 packages/shared-components/src/settings/NetworkProxyView/index.tsx diff --git a/apps/web/src/@types/global.d.ts b/apps/web/src/@types/global.d.ts index f6b9ff81f9a..cdf24065485 100644 --- a/apps/web/src/@types/global.d.ts +++ b/apps/web/src/@types/global.d.ts @@ -65,6 +65,7 @@ type ElectronChannel = | "userDownloadCompleted" | "userDownloadAction" | "openDesktopCapturerSourcePicker" + | "openProxySettings" | "userAccessToken" | "homeserverUrl" | "serverSupportedVersions" diff --git a/apps/web/src/BasePlatform.ts b/apps/web/src/BasePlatform.ts index 822b282e25d..67c57d8e2fd 100644 --- a/apps/web/src/BasePlatform.ts +++ b/apps/web/src/BasePlatform.ts @@ -316,6 +316,14 @@ export default abstract class BasePlatform { return false; } + /** + * Returns true if the platform supports network proxy configuration. + * @returns {boolean} whether the platform supports proxy configuration + */ + public supportsProxyConfiguration(): boolean { + return false; + } + public navigateForwardBack(back: boolean): void {} public getAvailableSpellCheckLanguages(): Promise | null { diff --git a/apps/web/src/components/views/settings/NetworkProxyModal.tsx b/apps/web/src/components/views/settings/NetworkProxyModal.tsx new file mode 100644 index 00000000000..6b44f8a8ad7 --- /dev/null +++ b/apps/web/src/components/views/settings/NetworkProxyModal.tsx @@ -0,0 +1,53 @@ +/* +Copyright 2026 tim2zg + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { + NetworkProxyView, + NetworkProxyViewModelImpl, + useCreateAutoDisposedViewModel, + type ProxyConfig +} from "@element-hq/web-shared-components"; + +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import BaseDialog from "../dialogs/BaseDialog"; +import { _t } from "../../../languageHandler"; + +interface Props { + onFinished: () => void; +} + +/** + * A modal dialog for configuring network proxy settings for the desktop application. + * Utilizes MVVM pattern with NetworkProxyViewModel and NetworkProxyView. + * + * @param props - The component props. + * @param props.onFinished - Callback invoked when the modal is closed or settings are saved. + */ +export const NetworkProxyModal: React.FC = ({ onFinished }) => { + const vm = useCreateAutoDisposedViewModel(() => new NetworkProxyViewModelImpl({ + initialConfig: SettingsStore.getValue("desktopProxyConfig") as ProxyConfig, + onSave: async (config) => { + await SettingsStore.setValue("desktopProxyConfig", null, SettingLevel.PLATFORM, config); + onFinished(); + }, + onCancel: onFinished, + })); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/web/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/apps/web/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index f94b2842f88..7b4dd8b4f31 100644 --- a/apps/web/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/apps/web/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -294,6 +294,36 @@ export default class SecurityUserSettingsTab extends React.Component + + + {description} + + { + Modal.createDialog(NetworkProxyModal, {}); + }} + > + {_t("settings|network_proxy|settings_button")} + + + + ); + } + public render(): React.ReactNode { const secureBackup = ; @@ -377,6 +407,8 @@ export default class SecurityUserSettingsTab extends React.Component {warning} @@ -386,6 +418,7 @@ export default class SecurityUserSettingsTab extends React.Component {privacySection} + {proxySection} {advancedSection} ); diff --git a/apps/web/src/vector/platform/ElectronPlatform.tsx b/apps/web/src/vector/platform/ElectronPlatform.tsx index 941963602e1..d9cbbdc350e 100644 --- a/apps/web/src/vector/platform/ElectronPlatform.tsx +++ b/apps/web/src/vector/platform/ElectronPlatform.tsx @@ -455,6 +455,10 @@ export default class ElectronPlatform extends BasePlatform { return true; } + public supportsProxyConfiguration(): boolean { + return !!this.supportedSettings?.desktopProxyConfig; + } + public supportsJitsiScreensharing(): boolean { // See https://github.com/element-hq/element-web/issues/4880 return false; diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 8f393756324..9e6023a93b2 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -48,6 +48,7 @@ export * from "./utils/Box"; export * from "./utils/Flex"; export * from "./utils/LinkedText"; export * from "./right-panel/WidgetContextMenu"; +export * from "./settings/NetworkProxyView"; export * from "./utils/VirtualizedList"; // Utils diff --git a/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyView.module.css b/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyView.module.css new file mode 100644 index 00000000000..d3dfebf157c --- /dev/null +++ b/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyView.module.css @@ -0,0 +1,87 @@ +/* + * Copyright 2026 tim2zg + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.networkProxyView { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); +} + +.modeSection { + display: flex; + flex-direction: column; + gap: var(--cpd-space-3x); +} + +.radioGroup { + display: flex; + flex-direction: column; + gap: var(--cpd-space-2x); +} + +.radioLabel { + display: flex; + align-items: center; + gap: var(--cpd-space-2x); + cursor: pointer; +} + +.configSection { + display: flex; + flex-direction: column; + gap: var(--cpd-space-3x); +} + +.field { + display: flex; + flex-direction: column; + gap: var(--cpd-space-1x); +} + +.fieldRow { + display: flex; + gap: var(--cpd-space-2x); +} + +.fieldRow > .field { + flex: 1; +} + +.portField { + width: 100px; +} + +.select { + width: 100%; + padding: var(--cpd-space-2x); + border: 1px solid var(--cpd-color-border-subtle); + border-radius: 8px; + background: var(--cpd-color-bg-canvas-default); + color: var(--cpd-color-text-primary); + font-family: inherit; + font-size: var(--cpd-font-size-body-md); +} + +.select:focus { + outline: 2px solid var(--cpd-color-border-focused); + outline-offset: 1px; +} + +.helperText { + color: var(--cpd-color-text-secondary); +} + +.errorText { + color: var(--cpd-color-text-critical-primary); +} + +.footer { + display: flex; + justify-content: flex-end; + gap: var(--cpd-space-2x); + margin-top: var(--cpd-space-2x); +} diff --git a/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyView.tsx b/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyView.tsx new file mode 100644 index 00000000000..e54ea7a4cfa --- /dev/null +++ b/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyView.tsx @@ -0,0 +1,229 @@ +/* + * Copyright 2026 tim2zg + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { + Button, + RadioInput, + TextInput, + PasswordInput, + Separator, + Text, +} from "@vector-im/compound-web"; + +import { type ViewModel, useViewModel } from "../../viewmodel"; +import { _t } from "../../utils/I18nApi"; +import styles from "./NetworkProxyView.module.css"; + +/** + * The snapshot representing the current state of the NetworkProxy configuration. + */ +export interface NetworkProxyViewSnapshot { + mode: "system" | "direct" | "custom"; + scheme: string; + host: string; + port: string; + username: string; + password: string; + bypass: string; + hasChanges: boolean; + isValid: boolean; + loading: boolean; + error: string | null; +} + +/** + * Actions that can be performed on the NetworkProxyView. + */ +export interface NetworkProxyViewActions { + updateMode: (mode: "system" | "direct" | "custom") => void; + updateScheme: (scheme: string) => void; + updateHost: (host: string) => void; + updatePort: (port: string) => void; + updateUsername: (username: string) => void; + updatePassword: (password: string) => void; + updateBypass: (bypass: string) => void; + save: () => Promise; + cancel: () => void; +} + +/** + * The view model for NetworkProxyView. + */ +export type NetworkProxyViewModel = ViewModel< + NetworkProxyViewSnapshot, + NetworkProxyViewActions +>; + +interface NetworkProxyViewProps { + /** + * The view model for the network proxy settings. + */ + vm: NetworkProxyViewModel; +} + +/** + * A component to configure network proxy settings. + * + * @example + * ```tsx + * + * ``` + */ +export function NetworkProxyView({ vm }: Readonly): JSX.Element { + const { + mode, + scheme, + host, + port, + username, + password, + bypass, + hasChanges, + isValid, + loading, + error, + } = useViewModel(vm); + + return ( +
+
+ + {_t("settings|network_proxy|connection_mode")} + + +
+ + + +
+ + {mode === "custom" && ( +
+ + {_t("common|configuration")} + +
+ {_t("common|protocol")} + +
+ +
+
+ {_t("settings|network_proxy|proxy_host")} + vm.updateHost(e.target.value)} + /> +
+
+ {_t("settings|network_proxy|port")} + vm.updatePort(e.target.value)} + min={1} + max={65535} + step={1} + /> +
+
+ +
+
+ {_t("common|username")} + vm.updateUsername(e.target.value)} + /> +
+
+ {_t("common|password")} + vm.updatePassword(e.target.value)} + /> +
+
+ + + {_t("settings|network_proxy|proxy_config_encrypted_system_storage")} + + +
+ {_t("settings|network_proxy|no_proxy_for_comma_separated")} + vm.updateBypass(e.target.value)} + /> +
+ + + {_t("settings|network_proxy|proxy_settings_updates_warning")} + +
+ )} + + {error && ( + + {error} + + )} +
+ +
+ + +
+
+ ); +} diff --git a/packages/shared-components/src/settings/NetworkProxyView/index.tsx b/packages/shared-components/src/settings/NetworkProxyView/index.tsx new file mode 100644 index 00000000000..27a1992b55f --- /dev/null +++ b/packages/shared-components/src/settings/NetworkProxyView/index.tsx @@ -0,0 +1,17 @@ +/* + * Copyright 2026 tim2zg + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { + NetworkProxyView, + type NetworkProxyViewModel, + type NetworkProxyViewSnapshot, + type NetworkProxyViewActions, +} from "./NetworkProxyView"; +export { + NetworkProxyViewModel as NetworkProxyViewModelImpl, + type NetworkProxyViewModelProps, +} from "./NetworkProxyViewModel"; From 9cea4341f0d68402189fe267e71ef801b8e87236 Mon Sep 17 00:00:00 2001 From: tim2zg Date: Tue, 17 Mar 2026 20:51:08 +0100 Subject: [PATCH 2/4] refactor: Add missing NetworkProxyViewModel for MVVM migration --- .../NetworkProxyView/NetworkProxyViewModel.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 packages/shared-components/src/settings/NetworkProxyView/NetworkProxyViewModel.ts diff --git a/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyViewModel.ts b/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyViewModel.ts new file mode 100644 index 00000000000..01a55ffec73 --- /dev/null +++ b/packages/shared-components/src/settings/NetworkProxyView/NetworkProxyViewModel.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2026 tim2zg + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { BaseViewModel } from "../../viewmodel/BaseViewModel"; +import { + type NetworkProxyViewModel as INetworkProxyViewModel, + type NetworkProxyViewSnapshot, +} from "./NetworkProxyView"; + +export interface NetworkProxyViewModelProps { + initialConfig: { + mode: "system" | "direct" | "custom"; + scheme?: string; + host?: string; + port?: number; + username?: string; + password?: string; + bypass?: string; + }; + onSave: (config: any) => Promise; + onCancel: () => void; +} + +export class NetworkProxyViewModel + extends BaseViewModel + implements INetworkProxyViewModel +{ + public constructor(props: NetworkProxyViewModelProps) { + super(props, { + mode: props.initialConfig.mode, + scheme: props.initialConfig.scheme ?? "http", + host: props.initialConfig.host ?? "", + port: props.initialConfig.port?.toString() ?? "", + username: props.initialConfig.username ?? "", + password: props.initialConfig.password ?? "", + bypass: props.initialConfig.bypass ?? "", + hasChanges: false, + isValid: true, + loading: false, + error: null, + }); + this.validate(); + } + + public updateMode = (mode: "system" | "direct" | "custom"): void => { + this.update({ mode }); + }; + + public updateScheme = (scheme: string): void => { + this.update({ scheme }); + }; + + public updateHost = (host: string): void => { + this.update({ host }); + }; + + public updatePort = (port: string): void => { + this.update({ port }); + }; + + public updateUsername = (username: string): void => { + this.update({ username }); + }; + + public updatePassword = (password: string): void => { + this.update({ password }); + }; + + public updateBypass = (bypass: string): void => { + this.update({ bypass }); + }; + + public save = async (): Promise => { + this.snapshot.merge({ loading: true, error: null }); + try { + const { mode, scheme, host, port, username, password, bypass } = this.getSnapshot(); + await this.props.onSave({ + mode, + scheme, + host, + port: parseInt(port, 10) || undefined, + username, + password, + bypass, + }); + this.snapshot.merge({ hasChanges: false, loading: false }); + } catch (e) { + this.snapshot.merge({ error: String(e), loading: false }); + } + }; + + public cancel = (): void => { + this.props.onCancel(); + }; + + private update(patch: Partial): void { + this.snapshot.merge(patch); + const next = this.getSnapshot(); + + // Calculate hasChanges + const initial = this.props.initialConfig; + const hasChanges = + next.mode !== initial.mode || + next.scheme !== (initial.scheme ?? "http") || + next.host !== (initial.host ?? "") || + next.port !== (initial.port?.toString() ?? "") || + next.username !== (initial.username ?? "") || + next.password !== (initial.password ?? "") || + next.bypass !== (initial.bypass ?? ""); + + this.snapshot.merge({ hasChanges }); + this.validate(); + } + + private validate(): void { + const next = this.getSnapshot(); + let isValid = true; + if (next.mode === "custom") { + if (!next.host || !next.port) { + isValid = false; + } + const portNum = parseInt(next.port, 10); + if (isNaN(portNum) || portNum < 1 || portNum > 65535) { + isValid = false; + } + } + if (next.isValid !== isValid) { + this.snapshot.merge({ isValid }); + } + } +} From a6bda2821a7a7cb293690ba51261b1727b4c95f0 Mon Sep 17 00:00:00 2001 From: tim2zg Date: Sun, 22 Mar 2026 10:53:53 +0100 Subject: [PATCH 3/4] feat: Add proxy settings to auth footer and finalize fixes - Added Network Proxy link to AuthFooter for pre-login configuration - Ensured proxy settings survive logout - Improved proxy rule generation for HTTPS support - Cleaned up MVVM implementation and translations --- apps/web/res/css/_components.pcss | 1 + .../views/settings/_NetworkProxyModal.pcss | 38 +++++++++++ .../src/components/views/auth/AuthFooter.tsx | 18 ++++++ apps/web/src/i18n/strings/en_EN.json | 16 +++++ apps/web/src/settings/Settings.tsx | 5 ++ .../views/settings/NetworkProxyModal-test.tsx | 64 +++++++++++++++++++ .../NetworkProxyView/NetworkProxyView.tsx | 2 +- 7 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 apps/web/res/css/views/settings/_NetworkProxyModal.pcss create mode 100644 apps/web/test/unit-tests/components/views/settings/NetworkProxyModal-test.tsx diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 78cb4b9b4af..b62cb868a37 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -323,6 +323,7 @@ @import "./views/settings/_JoinRuleSettings.pcss"; @import "./views/settings/_KeyboardShortcut.pcss"; @import "./views/settings/_LayoutSwitcher.pcss"; +@import "./views/settings/_NetworkProxyModal.pcss"; @import "./views/settings/_NotificationPusherSettings.pcss"; @import "./views/settings/_NotificationSettings2.pcss"; @import "./views/settings/_Notifications.pcss"; diff --git a/apps/web/res/css/views/settings/_NetworkProxyModal.pcss b/apps/web/res/css/views/settings/_NetworkProxyModal.pcss new file mode 100644 index 00000000000..e8f315f4037 --- /dev/null +++ b/apps/web/res/css/views/settings/_NetworkProxyModal.pcss @@ -0,0 +1,38 @@ +/* +Copyright 2026 tim2zg + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +.mx_NetworkProxyModal { + /* Override fixed dimensions from BaseDialog if needed, but usually we want it flexible */ + min-width: 400px; + max-width: 600px; + padding: var(--cpd-space-2x); +} + +/* + * Force standard inputs to show focus rings. + * Target any Compound container that has focus within it and apply the outline to its visual UI element. + * We target the container directly for focus-within and apply to its internal UI part. + */ +.mx_NetworkProxyModal div[class*="_container_"]:focus-within, +.mx_NetworkProxyModal div[class*="_container_"]:focus-within div[class*="_ui_"], +.mx_NetworkProxyModal div[class*="_container_"]:focus-within input { + outline: 2px solid var(--cpd-color-border-focused) !important; + outline-offset: 1px !important; +} + +/* Specific fix for PasswordInput where the container itself often needs the outline */ +.mx_NetworkProxyModal .mx_NetworkProxyModal_passwordInput:focus-within { + outline: 2px solid var(--cpd-color-border-focused) !important; + outline-offset: 1px !important; + border-radius: 8px !important; +} + +.mx_NetworkProxyModal select:focus { + outline: 2px solid var(--cpd-color-border-focused) !important; + outline-offset: 2px !important; + border-radius: 4px !important; +} diff --git a/apps/web/src/components/views/auth/AuthFooter.tsx b/apps/web/src/components/views/auth/AuthFooter.tsx index 1942bf04318..fc00e987284 100644 --- a/apps/web/src/components/views/auth/AuthFooter.tsx +++ b/apps/web/src/components/views/auth/AuthFooter.tsx @@ -11,6 +11,9 @@ import React, { type JSX, type ReactElement } from "react"; import SdkConfig from "../../../SdkConfig"; import { _t } from "../../../languageHandler"; +import PlatformPeg from "../../../PlatformPeg"; +import Modal from "../../../Modal"; +import { NetworkProxyModal } from "../settings/NetworkProxyModal"; const AuthFooter = (): ReactElement => { const brandingConfig = SdkConfig.getObject("branding"); @@ -29,6 +32,21 @@ const AuthFooter = (): ReactElement => { ); } + if (PlatformPeg.get().supportsProxyConfiguration()) { + authFooterLinks.push( + { + e.preventDefault(); + Modal.createDialog(NetworkProxyModal, {}); + }} + > + {_t("settings|network_proxy|title")} + , + ); + } + return (