diff --git a/nym-vpn/ui/src-tauri/src/commands/app_data.rs b/nym-vpn/ui/src-tauri/src/commands/app_data.rs index 539b3f413f..d4acdaa5f1 100644 --- a/nym-vpn/ui/src-tauri/src/commands/app_data.rs +++ b/nym-vpn/ui/src-tauri/src/commands/app_data.rs @@ -121,3 +121,47 @@ pub async fn set_entry_location_selector( .map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?; Ok(()) } + +#[instrument(skip(data_state))] +#[tauri::command] +pub async fn set_auto_connect( + data_state: State<'_, SharedAppData>, + entry_selector: bool, +) -> Result<(), CmdError> { + debug!("set_auto_connect"); + + let mut app_data_store = data_state.lock().await; + let mut app_data = app_data_store + .read() + .await + .map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?; + app_data.autoconnect = Some(entry_selector); + app_data_store.data = app_data; + app_data_store + .write() + .await + .map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?; + Ok(()) +} + +#[instrument(skip(data_state))] +#[tauri::command] +pub async fn set_monitoring( + data_state: State<'_, SharedAppData>, + entry_selector: bool, +) -> Result<(), CmdError> { + debug!("set_monitoring"); + + let mut app_data_store = data_state.lock().await; + let mut app_data = app_data_store + .read() + .await + .map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?; + app_data.monitoring = Some(entry_selector); + app_data_store.data = app_data; + app_data_store + .write() + .await + .map_err(|e| CmdError::new(CmdErrorSource::InternalError, e.to_string()))?; + Ok(()) +} diff --git a/nym-vpn/ui/src-tauri/src/main.rs b/nym-vpn/ui/src-tauri/src/main.rs index 6cdc3815ab..64eb32c2e3 100644 --- a/nym-vpn/ui/src-tauri/src/main.rs +++ b/nym-vpn/ui/src-tauri/src/main.rs @@ -108,6 +108,8 @@ async fn main() -> Result<()> { app_data::set_app_data, app_data::set_ui_theme, app_data::set_entry_location_selector, + app_data::set_monitoring, + app_data::set_auto_connect, app_data::get_node_countries, app_data::set_root_font_size, node_location::set_node_location, diff --git a/nym-vpn/ui/src-tauri/tauri.conf.json b/nym-vpn/ui/src-tauri/tauri.conf.json index 93898ba01e..646fe5971a 100644 --- a/nym-vpn/ui/src-tauri/tauri.conf.json +++ b/nym-vpn/ui/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "nym-vpn", - "version": "0.0.0" + "version": "0.0.1" }, "tauri": { "updater": { diff --git a/nym-vpn/ui/src/constants.ts b/nym-vpn/ui/src/constants.ts index ee33ed2fcd..3193f21cf6 100644 --- a/nym-vpn/ui/src/constants.ts +++ b/nym-vpn/ui/src/constants.ts @@ -1,6 +1,10 @@ export const routes = { root: '/', settings: '/settings', + display: '/settings/display', + logs: '/settings/logs', + feedback: '/settings/feedback', + legal: '/settings/legal', entryNodeLocation: '/entry-node-location', exitNodeLocation: '/exit-node-location', } as const; diff --git a/nym-vpn/ui/src/i18n/en/common.json b/nym-vpn/ui/src/i18n/en/common.json index 824ef148fb..b959ce36db 100644 --- a/nym-vpn/ui/src/i18n/en/common.json +++ b/nym-vpn/ui/src/i18n/en/common.json @@ -3,5 +3,9 @@ "disconnect": "Disconnect", "first-hop-selection": "First hop selection", "last-hop-selection": "Last hop selection", - "settings": "Settings" + "settings": "Settings", + "display-theme": "Display theme", + "logs": "Logs", + "feedback": "Feedback", + "legal": "Legal" } diff --git a/nym-vpn/ui/src/i18n/en/settings.json b/nym-vpn/ui/src/i18n/en/settings.json index dd9a88fffe..a2c67b4e14 100644 --- a/nym-vpn/ui/src/i18n/en/settings.json +++ b/nym-vpn/ui/src/i18n/en/settings.json @@ -1,3 +1,20 @@ { - "entry-selector": "Entry location selector" + "auto-connect": { + "title": "Auto-connect", + "desc": "Auto connect at app startup" + }, + "entry-selector": { + "title": "Entry location selector", + "desc": "Manually select your entry hop" + }, + "feedback": "Feedback", + "error-reporting": { + "title": "Error reporting", + "desc": "Help us improve NymVPN" + }, + "faq": "FAQ", + "display-theme": "Display theme", + "logs": "Logs", + "legal": "Legal", + "quit": "Quit NymVPN" } diff --git a/nym-vpn/ui/src/pages/home/Home.tsx b/nym-vpn/ui/src/pages/home/Home.tsx index ef6d8ede54..63ff90e0ac 100644 --- a/nym-vpn/ui/src/pages/home/Home.tsx +++ b/nym-vpn/ui/src/pages/home/Home.tsx @@ -12,8 +12,14 @@ import ConnectionStatus from './ConnectionStatus'; import HopSelect from './HopSelect'; function Home() { - const { state, loading, exitNodeLocation, defaultNodeLocation } = - useMainState(); + const { + state, + loading, + entryNodeLocation, + exitNodeLocation, + entrySelector, + defaultNodeLocation, + } = useMainState(); const dispatch = useMainDispatch() as StateDispatch; const navigate = useNavigate(); const { t } = useTranslation('home'); @@ -77,6 +83,17 @@ function Home() {
{t('select-node-title')}
+ {entrySelector && ( + { + if (state === 'Disconnected') { + navigate(routes.entryNodeLocation); + } + }} + nodeHop="entry" + /> + )} { diff --git a/nym-vpn/ui/src/pages/settings/Settings.tsx b/nym-vpn/ui/src/pages/settings/Settings.tsx index 7d1044bc8a..9166ddcdb5 100644 --- a/nym-vpn/ui/src/pages/settings/Settings.tsx +++ b/nym-vpn/ui/src/pages/settings/Settings.tsx @@ -2,61 +2,216 @@ import { useEffect, useState } from 'react'; import clsx from 'clsx'; import { invoke } from '@tauri-apps/api'; import { Switch } from '@headlessui/react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { routes } from '../../constants'; import { useMainDispatch, useMainState } from '../../contexts'; import { StateDispatch } from '../../types'; -import UiScaler from './UiScaler'; +import SettingsGroup from './SettingsGroup'; function Settings() { const state = useMainState(); + const navigate = useNavigate(); const dispatch = useMainDispatch() as StateDispatch; + const { t } = useTranslation('settings'); - const [darkModeEnabled, setDarkModeEnabled] = useState( - state.uiTheme === 'Dark', - ); + const [entrySelector, setEntrySelector] = useState(state.entrySelector); + const [autoConnect, setAutoConnect] = useState(state.autoConnect); + const [monitoring, setMonitoring] = useState(state.monitoring); useEffect(() => { - setDarkModeEnabled(state.uiTheme === 'Dark'); + setEntrySelector(state.entrySelector); + setAutoConnect(state.autoConnect); + setMonitoring(state.monitoring); }, [state]); - const handleThemeChange = async (darkMode: boolean) => { - if (darkMode && state.uiTheme === 'Light') { - dispatch({ type: 'set-ui-theme', theme: 'Dark' }); - } else if (!darkMode && state.uiTheme === 'Dark') { - dispatch({ type: 'set-ui-theme', theme: 'Light' }); - } - invoke('set_ui_theme', { theme: darkMode ? 'Dark' : 'Light' }).catch( - (e) => { - console.log(e); - }, - ); + const handleEntrySelectorChange = async () => { + const isSelected = !state.entrySelector; + dispatch({ type: 'set-entry-selector', entrySelector: isSelected }); + invoke('set_entry_location_selector', { + entrySelector: isSelected, + }).catch((e) => { + console.log(e); + }); + }; + + const handleAutoConnectChanged = async () => { + const isSelected = !state.autoConnect; + dispatch({ type: 'set-auto-connect', autoConnect: isSelected }); + invoke('set_auto_connect', { autoConnect: isSelected }).catch((e) => { + console.log(e); + }); + }; + + const handleMonitoringChanged = async () => { + const isSelected = !state.monitoring; + dispatch({ type: 'set-monitoring', monitoring: isSelected }); + invoke('set_monitoring', { monitoring: isSelected }).catch((e) => { + console.log(e); + }); }; return ( -
-
-

- Dark Mode -

- - Dark mode - - +
+ + + + ), + }, + { + title: t('entry-selector.title'), + desc: t('entry-selector.desc'), + leadingIcon: 'looks_two', + trailing: ( + + + + ), + }, + ]} + /> + { + navigate(routes.display); + }, + trailing: ( +
+ arrow_right +
+ ), + }, + ]} + /> + { + navigate(routes.logs); + }, + trailing: ( +
+ arrow_right +
+ ), + }, + ]} + /> + { + navigate(routes.feedback); + }, + trailing: ( +
+ arrow_right +
+ ), + }, + { + title: t('error-reporting.title'), + desc: t('error-reporting.desc'), + leadingIcon: 'error', + trailing: ( + + + + ), + }, + { + title: t('faq'), + leadingIcon: 'help', + trailing: ( +
launch
+ ), + }, + ]} + /> + { + navigate(routes.legal); + }, + trailing: ( +
+ arrow_right +
+ ), + }, + ]} + /> + { + //TODO shutdown gracefully + }, + }, + ]} + /> +
+ Version {state.version}
-
); } diff --git a/nym-vpn/ui/src/pages/settings/SettingsGroup.tsx b/nym-vpn/ui/src/pages/settings/SettingsGroup.tsx new file mode 100644 index 0000000000..e1bd46a30f --- /dev/null +++ b/nym-vpn/ui/src/pages/settings/SettingsGroup.tsx @@ -0,0 +1,66 @@ +import clsx from 'clsx'; +import { ReactNode } from 'react'; +import { RadioGroup } from '@headlessui/react'; + +type Setting = { + title: string; + leadingIcon?: string; + desc?: string; + onClick?: () => void; + trailing?: ReactNode; +}; + +interface Props { + settings: Setting[]; +} + +function SettingsGroup({ settings }: Props) { + return ( + + {settings.map((setting, index) => ( + +
+ {setting.leadingIcon && ( + {setting.leadingIcon} + )} +
+
+ + {setting.title} + + + {setting.desc} + +
+
+
{setting.trailing}
+
+
+ ))} +
+ ); +} + +export default SettingsGroup; diff --git a/nym-vpn/ui/src/pages/settings/SettingsLayout.tsx b/nym-vpn/ui/src/pages/settings/SettingsLayout.tsx new file mode 100644 index 0000000000..90015a54fc --- /dev/null +++ b/nym-vpn/ui/src/pages/settings/SettingsLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; + +function SettingsLayout() { + return ( +
+ +
+ ); +} + +export default SettingsLayout; diff --git a/nym-vpn/ui/src/pages/settings/display/Display.tsx b/nym-vpn/ui/src/pages/settings/display/Display.tsx new file mode 100644 index 0000000000..a783161baf --- /dev/null +++ b/nym-vpn/ui/src/pages/settings/display/Display.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import clsx from 'clsx'; +import { invoke } from '@tauri-apps/api'; +import { Switch } from '@headlessui/react'; +import { useMainDispatch, useMainState } from '../../../contexts'; +import { StateDispatch } from '../../../types'; +import UiScaler from './UiScaler'; + +function Display() { + const state = useMainState(); + const dispatch = useMainDispatch() as StateDispatch; + + const [darkModeEnabled, setDarkModeEnabled] = useState( + state.uiTheme === 'Dark', + ); + + useEffect(() => { + setDarkModeEnabled(state.uiTheme === 'Dark'); + }, [state]); + + const handleThemeChange = async (darkMode: boolean) => { + if (darkMode && state.uiTheme === 'Light') { + dispatch({ type: 'set-ui-theme', theme: 'Dark' }); + } else if (!darkMode && state.uiTheme === 'Dark') { + dispatch({ type: 'set-ui-theme', theme: 'Light' }); + } + invoke('set_ui_theme', { theme: darkMode ? 'Dark' : 'Light' }).catch( + (e) => { + console.log(e); + }, + ); + }; + + return ( +
+
+

+ Dark Mode +

+ + Dark mode + + +
+ +
+ ); +} + +export default Display; diff --git a/nym-vpn/ui/src/pages/settings/UiScaler.tsx b/nym-vpn/ui/src/pages/settings/display/UiScaler.tsx similarity index 91% rename from nym-vpn/ui/src/pages/settings/UiScaler.tsx rename to nym-vpn/ui/src/pages/settings/display/UiScaler.tsx index 7cde54f24b..c5a0652fcc 100644 --- a/nym-vpn/ui/src/pages/settings/UiScaler.tsx +++ b/nym-vpn/ui/src/pages/settings/display/UiScaler.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useEffect, useState } from 'react'; import { invoke } from '@tauri-apps/api'; -import { useMainDispatch, useMainState } from '../../contexts'; -import { CmdError, StateDispatch } from '../../types'; +import { useMainDispatch, useMainState } from '../../../contexts'; +import { CmdError, StateDispatch } from '../../../types'; function UiScaler() { const [slideValue, setSlideValue] = useState(12); diff --git a/nym-vpn/ui/src/pages/settings/display/index.ts b/nym-vpn/ui/src/pages/settings/display/index.ts new file mode 100644 index 0000000000..fa2dc562ff --- /dev/null +++ b/nym-vpn/ui/src/pages/settings/display/index.ts @@ -0,0 +1 @@ +export { default as Display } from './Display'; diff --git a/nym-vpn/ui/src/pages/settings/feedback/Feedback.tsx b/nym-vpn/ui/src/pages/settings/feedback/Feedback.tsx new file mode 100644 index 0000000000..a2f48556f3 --- /dev/null +++ b/nym-vpn/ui/src/pages/settings/feedback/Feedback.tsx @@ -0,0 +1,9 @@ +function Feedback() { + return ( +
+
+
+ ); +} + +export default Feedback; diff --git a/nym-vpn/ui/src/pages/settings/feedback/index.ts b/nym-vpn/ui/src/pages/settings/feedback/index.ts new file mode 100644 index 0000000000..4a06655c88 --- /dev/null +++ b/nym-vpn/ui/src/pages/settings/feedback/index.ts @@ -0,0 +1 @@ +export { default as Feedback } from './Feedback'; diff --git a/nym-vpn/ui/src/pages/settings/index.ts b/nym-vpn/ui/src/pages/settings/index.ts index 1f16002f16..65ba8ccb8d 100644 --- a/nym-vpn/ui/src/pages/settings/index.ts +++ b/nym-vpn/ui/src/pages/settings/index.ts @@ -1 +1,5 @@ export { default as Settings } from './Settings'; +export { default as SettingsLayout } from './SettingsLayout'; +export * from './display'; +export * from './feedback'; +export * from './legal'; diff --git a/nym-vpn/ui/src/pages/settings/legal/Legal.tsx b/nym-vpn/ui/src/pages/settings/legal/Legal.tsx new file mode 100644 index 0000000000..e7267f8cee --- /dev/null +++ b/nym-vpn/ui/src/pages/settings/legal/Legal.tsx @@ -0,0 +1,9 @@ +function Legal() { + return ( +
+
+
+ ); +} + +export default Legal; diff --git a/nym-vpn/ui/src/pages/settings/legal/index.ts b/nym-vpn/ui/src/pages/settings/legal/index.ts new file mode 100644 index 0000000000..77d3ce9eb2 --- /dev/null +++ b/nym-vpn/ui/src/pages/settings/legal/index.ts @@ -0,0 +1 @@ +export { default as Legal } from './Legal'; diff --git a/nym-vpn/ui/src/router.tsx b/nym-vpn/ui/src/router.tsx index a7b8dc5d39..02300ee738 100644 --- a/nym-vpn/ui/src/router.tsx +++ b/nym-vpn/ui/src/router.tsx @@ -1,5 +1,15 @@ import { createBrowserRouter } from 'react-router-dom'; -import { Error, Home, NavLayout, NodeLocation, Settings } from './pages'; +import { + Display, + Error, + Feedback, + Home, + Legal, + NavLayout, + NodeLocation, + Settings, + SettingsLayout, +} from './pages'; import { routes } from './constants'; const router = createBrowserRouter([ @@ -14,8 +24,30 @@ const router = createBrowserRouter([ }, { path: routes.settings, - element: , + element: , errorElement: , + children: [ + { + element: , + errorElement: , + index: true, + }, + { + path: routes.display, + element: , + errorElement: , + }, + { + path: routes.feedback, + element: , + errorElement: , + }, + { + path: routes.legal, + element: , + errorElement: , + }, + ], }, { path: routes.entryNodeLocation, diff --git a/nym-vpn/ui/src/state/main.ts b/nym-vpn/ui/src/state/main.ts index 824da4c14b..4c8a6d1fb9 100644 --- a/nym-vpn/ui/src/state/main.ts +++ b/nym-vpn/ui/src/state/main.ts @@ -19,9 +19,12 @@ export type StateAction = | { type: 'new-progress-message'; message: ConnectProgressMsg } | { type: 'connect' } | { type: 'disconnect' } + | { type: 'set-version'; version: string } | { type: 'set-connected'; startTime: number } | { type: 'set-connection-start-time'; startTime?: number | null } | { type: 'set-disconnected' } + | { type: 'set-auto-connect'; autoConnect: boolean } + | { type: 'set-monitoring'; monitoring: boolean } | { type: 'reset' } | { type: 'set-ui-theme'; theme: UiTheme } | { type: 'set-countries'; countries: Country[] } @@ -31,12 +34,15 @@ export type StateAction = export const initialState: AppState = { state: 'Disconnected', + version: null, loading: false, vpnMode: 'TwoHop', entrySelector: false, tunnel: { name: 'nym', id: 'nym' }, uiTheme: 'Light', progressMessages: [], + autoConnect: false, + monitoring: false, entryNodeLocation: null, exitNodeLocation: null, defaultNodeLocation: { @@ -70,6 +76,16 @@ export function reducer(state: AppState, action: StateAction): AppState { ...state, entrySelector: action.entrySelector, }; + case 'set-auto-connect': + return { + ...state, + autoConnect: action.autoConnect, + }; + case 'set-monitoring': + return { + ...state, + monitoring: action.monitoring, + }; case 'set-countries': return { ...state, @@ -101,6 +117,11 @@ export function reducer(state: AppState, action: StateAction): AppState { case 'disconnect': { return { ...state, state: 'Disconnecting', loading: true }; } + case 'set-version': + return { + ...state, + version: action.version, + }; case 'set-connected': { console.log( `__REDUCER [set-connected] changing connection state to Connected`, @@ -152,6 +173,7 @@ export function reducer(state: AppState, action: StateAction): AppState { ...state, rootFontSize: action.size, }; + case 'reset': return initialState; } diff --git a/nym-vpn/ui/src/state/provider.tsx b/nym-vpn/ui/src/state/provider.tsx index dcbf00946e..482ea353bd 100644 --- a/nym-vpn/ui/src/state/provider.tsx +++ b/nym-vpn/ui/src/state/provider.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useReducer } from 'react'; import { invoke } from '@tauri-apps/api'; +import { getVersion } from '@tauri-apps/api/app'; import { MainDispatchContext, MainStateContext } from '../contexts'; import { AppDataFromBackend, @@ -41,6 +42,17 @@ export function MainStateProvider({ children }: Props) { return await invoke('get_default_node_location'); }; + getVersion() + .then((version) => + dispatch({ + type: 'set-version', + version, + }), + ) + .catch((e) => { + console.warn(`command [set-version] returned an error: ${e}`); + }); + getInitialConnectionState() .then((state) => dispatch({ type: 'change-connection-state', state })) .catch((e: CmdError) => { @@ -99,6 +111,8 @@ export function MainStateProvider({ children }: Props) { entrySelector: data.entry_location_selector || false, uiTheme: data.ui_theme || 'Light', vpnMode: data.vpn_mode || 'TwoHop', + autoConnect: data.autoconnect || false, + monitoring: data.monitoring || false, rootFontSize: data.ui_root_font_size || DefaultRootFontSize, }; if (data.entry_node_location) { diff --git a/nym-vpn/ui/src/types/app-state.ts b/nym-vpn/ui/src/types/app-state.ts index 7ace38c3c5..bbbd887c8c 100644 --- a/nym-vpn/ui/src/types/app-state.ts +++ b/nym-vpn/ui/src/types/app-state.ts @@ -19,6 +19,7 @@ export interface TunnelConfig { export type AppState = { state: ConnectionState; + version: string | null; loading: boolean; error?: string | null; progressMessages: ConnectProgressMsg[]; @@ -27,6 +28,8 @@ export type AppState = { tunnel: TunnelConfig; uiTheme: 'Light' | 'Dark'; entrySelector: boolean; + autoConnect: boolean; + monitoring: boolean; entryNodeLocation: Country | null; exitNodeLocation: Country | null; defaultNodeLocation: Country; diff --git a/nym-vpn/ui/src/ui/TopBar.tsx b/nym-vpn/ui/src/ui/TopBar.tsx index 117ec3ddaa..809c9a2cf0 100644 --- a/nym-vpn/ui/src/ui/TopBar.tsx +++ b/nym-vpn/ui/src/ui/TopBar.tsx @@ -45,6 +45,34 @@ export default function TopBar() { navigate(-1); }, }, + '/settings/display': { + title: t('display-theme'), + leftIcon: 'arrow_back', + handleLeftNav: () => { + navigate(-1); + }, + }, + '/settings/logs': { + title: t('logs'), + leftIcon: 'arrow_back', + handleLeftNav: () => { + navigate(-1); + }, + }, + '/settings/feedback': { + title: t('feedback'), + leftIcon: 'arrow_back', + handleLeftNav: () => { + navigate(-1); + }, + }, + '/settings/legal': { + title: t('legal'), + leftIcon: 'arrow_back', + handleLeftNav: () => { + navigate(-1); + }, + }, '/entry-node-location': { title: t('first-hop-selection'), leftIcon: 'arrow_back', diff --git a/nym-vpn/ui/tailwind.config.ts b/nym-vpn/ui/tailwind.config.ts index 87fbcce01e..247a3df63c 100644 --- a/nym-vpn/ui/tailwind.config.ts +++ b/nym-vpn/ui/tailwind.config.ts @@ -34,6 +34,7 @@ export default { cornflower: '#7075FF', // [DL] error status text teaberry: '#E33B5A', + comet: '#625B71', // [DL] "Connected" status text 'vert-menthe': '#2BC761', // [D] main titles text