From 8b26319ed20361064750d363f55ccad94ee1b27f Mon Sep 17 00:00:00 2001 From: Adam McQuilkin <46639306+ajmcquilkin@users.noreply.github.com> Date: Mon, 19 Feb 2024 14:12:08 -0800 Subject: [PATCH 1/3] Refactored invoke calls into api functions (non-functional) --- src-tauri/src/ipc/commands/radio.rs | 1 + src/api/backend/connection.ts | 65 +++++++++++++++++++++ src/api/backend/mesh.ts | 43 ++++++++++++++ src/api/backend/radio.ts | 59 +++++++++++++++++++ src/features/algorithms/actions.ts | 13 ----- src/features/algorithms/sagas.ts | 41 -------------- src/features/algorithms/selectors.ts | 7 --- src/features/algorithms/slice.ts | 30 ---------- src/features/config/sagas.ts | 11 ++-- src/features/device/sagas.ts | 84 ++++++++++++++-------------- src/store/index.ts | 2 - src/store/saga.ts | 8 +-- tsconfig.json | 44 ++++----------- vite.config.ts | 1 + 14 files changed, 227 insertions(+), 182 deletions(-) create mode 100644 src/api/backend/connection.ts create mode 100644 src/api/backend/mesh.ts create mode 100644 src/api/backend/radio.ts delete mode 100644 src/features/algorithms/actions.ts delete mode 100644 src/features/algorithms/sagas.ts delete mode 100644 src/features/algorithms/selectors.ts delete mode 100644 src/features/algorithms/slice.ts diff --git a/src-tauri/src/ipc/commands/radio.rs b/src-tauri/src/ipc/commands/radio.rs index 4c81a228..e73d7625 100644 --- a/src-tauri/src/ipc/commands/radio.rs +++ b/src-tauri/src/ipc/commands/radio.rs @@ -58,6 +58,7 @@ pub async fn update_device_user( Ok(()) } +// UNUSED #[tauri::command] pub async fn start_configuration_transaction( device_key: DeviceKey, diff --git a/src/api/backend/connection.ts b/src/api/backend/connection.ts new file mode 100644 index 00000000..ca85e08b --- /dev/null +++ b/src/api/backend/connection.ts @@ -0,0 +1,65 @@ +import { invoke } from "@tauri-apps/api"; +import { app_ipc_DeviceBulkConfig } from "@bindings/index"; +import { DeviceKey } from "@utils/connections"; + +export const updateDeviceConfigBulk = async ( + deviceKey: DeviceKey, + config: app_ipc_DeviceBulkConfig, +) => { + const response = (await invoke("update_device_config_bulk", { + deviceKey: deviceKey, + config: config, + })) as undefined; + + return response; +}; + +export const requestAutoConnectPort = async () => { + const response = (await invoke("request_autoconnect_port")) as string; + + return response; +}; + +export const getAllSerialPorts = async () => { + const response = (await invoke("get_all_serial_ports")) as string[]; + + return response; +}; + +export const connectToSerialPort = async ( + portName: string, + baudRate?: number, + dtr?: boolean, + rts?: boolean, +) => { + const response = (await invoke("connect_to_serial_port", { + portName, + baudRate, + dtr, + rts, + })) as undefined; + + return response; +}; + +export const connectToTcpPort = async (socketAddress: string) => { + const response = (await invoke("connect_to_tcp_port", { + address: socketAddress, + })) as undefined; + + return response; +}; + +export const dropDeviceConnection = async (deviceKey: DeviceKey) => { + const response = (await invoke("drop_device_connection", { + deviceKey, + })) as undefined; + + return response; +}; + +export const dropAllDeviceConnections = async () => { + const response = (await invoke("drop_all_device_connections")) as undefined; + + return response; +}; diff --git a/src/api/backend/mesh.ts b/src/api/backend/mesh.ts new file mode 100644 index 00000000..4c12097a --- /dev/null +++ b/src/api/backend/mesh.ts @@ -0,0 +1,43 @@ +import { invoke } from "@tauri-apps/api"; +import { app_device_NormalizedWaypoint } from "@bindings/index"; +import { DeviceKey } from "@utils/connections"; + +export const sendText = async ( + deviceKey: DeviceKey, + deviceChannel: number, + text: string, +) => { + const response = (await invoke("send_text", { + deviceKey: deviceKey, + channel: deviceChannel, + text: text, + })) as undefined; + + return response; +}; + +export const sendWaypoint = async ( + deviceKey: DeviceKey, + deviceChannel: number, + waypoint: app_device_NormalizedWaypoint, +) => { + const response = (await invoke("send_waypoint", { + deviceKey: deviceKey, + channel: deviceChannel, + waypoint: waypoint, + })) as undefined; + + return response; +}; + +export const deleteWaypoint = async ( + deviceKey: DeviceKey, + waypointId: number, +) => { + const response = (await invoke("delete_waypoint", { + deviceKey: deviceKey, + waypointId: waypointId, + })) as undefined; + + return response; +}; diff --git a/src/api/backend/radio.ts b/src/api/backend/radio.ts new file mode 100644 index 00000000..f167d385 --- /dev/null +++ b/src/api/backend/radio.ts @@ -0,0 +1,59 @@ +import { invoke } from "@tauri-apps/api"; +import { + app_ipc_DeviceBulkConfig, + meshtastic_protobufs_Config, + meshtastic_protobufs_User, +} from "@bindings/index"; +import { DeviceKey } from "@utils/connections"; + +export const updateDeviceConfig = async ( + deviceKey: DeviceKey, + config: meshtastic_protobufs_Config, +) => { + const response = (await invoke("update_device_config", { + deviceKey: deviceKey, + config: config, + })) as undefined; + + return response; +}; + +export const updateDeviceUser = async ( + deviceKey: DeviceKey, + user: meshtastic_protobufs_User, +) => { + const response = (await invoke("update_device_user", { + deviceKey: deviceKey, + user: user, + })) as undefined; + + return response; +}; + +export const startConfigurationTransaction = async (deviceKey: DeviceKey) => { + const response = (await invoke("start_configuration_transaction", { + deviceKey: deviceKey, + })) as undefined; + + return response; +}; + +export const commitConfigurationTransaction = async (deviceKey: DeviceKey) => { + const response = (await invoke("commit_configuration_transaction", { + deviceKey: deviceKey, + })) as undefined; + + return response; +}; + +export const updateDeviceConfigBulk = async ( + deviceKey: DeviceKey, + config: app_ipc_DeviceBulkConfig, +) => { + const response = (await invoke("update_device_config_bulk", { + deviceKey: deviceKey, + config: config, + })) as undefined; + + return response; +}; diff --git a/src/features/algorithms/actions.ts b/src/features/algorithms/actions.ts deleted file mode 100644 index 142d5e43..00000000 --- a/src/features/algorithms/actions.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createAction } from "@reduxjs/toolkit"; - -export type AlgorithmConfigFlags = { - articulationPoint?: boolean; - diffusionCentrality?: boolean; - globalMincut?: boolean; - mostSimilarTimeline?: boolean; - predictedState?: boolean; -}; - -export const requestRunAllAlgorithms = createAction<{ - flags: AlgorithmConfigFlags; -}>("@device/request-run-all-algorithms"); diff --git a/src/features/algorithms/sagas.ts b/src/features/algorithms/sagas.ts deleted file mode 100644 index cad271d7..00000000 --- a/src/features/algorithms/sagas.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { invoke } from "@tauri-apps/api"; -import { all, call, put, takeEvery } from "redux-saga/effects"; - -import { requestRunAllAlgorithms } from "@features/algorithms/actions"; -import { - IAlgorithmsState, - algorithmsSliceActions, -} from "@features/algorithms/slice"; -import { requestSliceActions } from "@features/requests/slice"; - -import type { CommandError } from "@utils/errors"; - -function* runAllAlgorithmsWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - const results = (yield call( - invoke, - "run_algorithms", - action.payload, - )) as IAlgorithmsState; - - yield put(algorithmsSliceActions.setApResult(results.apResult)); - yield put(algorithmsSliceActions.setMincutResult(results.mincutResult)); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -export function* algorithmsSaga() { - yield all([takeEvery(requestRunAllAlgorithms.type, runAllAlgorithmsWorker)]); -} diff --git a/src/features/algorithms/selectors.ts b/src/features/algorithms/selectors.ts deleted file mode 100644 index f36225c3..00000000 --- a/src/features/algorithms/selectors.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { RootState } from "@app/store"; -import type { IAlgorithmsState } from "@features/algorithms/slice"; - -export const selectAlgorithmsResults = - () => - (state: RootState): IAlgorithmsState => - state.algorithms; diff --git a/src/features/algorithms/slice.ts b/src/features/algorithms/slice.ts deleted file mode 100644 index 3cc901e9..00000000 --- a/src/features/algorithms/slice.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { PayloadAction, createSlice } from "@reduxjs/toolkit"; - -export interface IAlgorithmsState { - apResult: string[] | null; - mincutResult: [string, string][] | null; -} - -export const initialAlgorithmsState: IAlgorithmsState = { - apResult: null, - mincutResult: null, -}; - -export const algorithmsSlice = createSlice({ - name: "algorithms", - initialState: initialAlgorithmsState, - reducers: { - setApResult: (state, action: PayloadAction) => { - state.apResult = action.payload; - }, - setMincutResult: ( - state, - action: PayloadAction<[string, string][] | null>, - ) => { - state.mincutResult = action.payload; - }, - }, -}); - -export const { actions: algorithmsSliceActions, reducer: algorithmsReducer } = - algorithmsSlice; diff --git a/src/features/config/sagas.ts b/src/features/config/sagas.ts index c592762b..c532536c 100644 --- a/src/features/config/sagas.ts +++ b/src/features/config/sagas.ts @@ -1,10 +1,11 @@ -import { invoke } from "@tauri-apps/api"; -import { all, call, put, select, takeEvery } from "redux-saga/effects"; +import { all, put, select, takeEvery } from "redux-saga/effects"; import cloneDeep from "lodash.clonedeep"; import merge from "lodash.merge"; import mergeWith from "lodash.mergewith"; +import * as radioApi from "@api/backend/radio"; + import type { app_device_MeshChannel, app_ipc_DeviceBulkConfig, @@ -27,7 +28,6 @@ import { requestSliceActions } from "@features/requests/slice"; import type { CommandError } from "@utils/errors"; import { getMeshChannelFromCurrentConfig } from "@utils/form"; -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Saga, should be refactored in the future function* commitConfigWorker(action: ReturnType) { try { yield put(requestSliceActions.setRequestPending({ name: action.type })); @@ -127,10 +127,7 @@ function* commitConfigWorker(action: ReturnType) { // Dispatch update to backend - yield call(invoke, "update_device_config_bulk", { - deviceKey: primaryDeviceKey, - config: configPayload, - }); + radioApi.updateDeviceConfigBulk(primaryDeviceKey, configPayload); // Clear temporary config fields diff --git a/src/features/device/sagas.ts b/src/features/device/sagas.ts index 3e911918..df7468b4 100644 --- a/src/features/device/sagas.ts +++ b/src/features/device/sagas.ts @@ -1,7 +1,10 @@ -import { invoke } from "@tauri-apps/api"; import type { Task } from "redux-saga"; import { all, call, cancel, fork, put, takeEvery } from "redux-saga/effects"; +import * as radioApi from "@api/backend/radio"; +import * as meshApi from "@api/backend/mesh"; +import * as connectionApi from "@api/backend/connection"; + import { connectionSliceActions } from "@features/connection/slice"; import { requestAutoConnectPort, @@ -38,13 +41,13 @@ import type { CommandError } from "@utils/errors"; import { error } from "@utils/errors"; // Currently this only suports serial ports -function* getAutoConnectPortWorker( +async function* getAutoConnectPortWorker( action: ReturnType, ) { try { yield put(requestSliceActions.setRequestPending({ name: action.type })); - const portName = (yield call(invoke, "request_autoconnect_port")) as string; + const portName = await connectionApi.requestAutoConnectPort(); yield put(deviceSliceActions.setAutoConnectPort(portName)); @@ -97,16 +100,13 @@ function* subscribeAll() { ]); } -function* getAvailableSerialPortsWorker( +async function* getAvailableSerialPortsWorker( action: ReturnType, ) { try { yield put(requestSliceActions.setRequestPending({ name: action.type })); - const serialPorts = (yield call( - invoke, - "get_all_serial_ports", - )) as string[]; + const serialPorts = await connectionApi.getAllSerialPorts(); yield put(deviceSliceActions.setAvailableSerialPorts(serialPorts)); yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); @@ -135,7 +135,7 @@ function* initializeApplicationWorker( } } -function* connectToDeviceWorker( +async function* connectToDeviceWorker( action: ReturnType, ) { let subscribeTask: Task | null = null; @@ -162,15 +162,16 @@ function* connectToDeviceWorker( subscribeTask = yield fork(subscribeAll) as unknown as Task; if (action.payload.params.type === ConnectionType.SERIAL) { - yield call(invoke, "connect_to_serial_port", { - portName: action.payload.params.portName, - }); + await connectionApi.connectToSerialPort( + action.payload.params.portName, + undefined, + action.payload.params.dtr, + action.payload.params.rts, + ); } if (action.payload.params.type === ConnectionType.TCP) { - yield call(invoke, "connect_to_tcp_port", { - address: action.payload.params.socketAddress, - }); + await connectionApi.connectToTcpPort(action.payload.params.socketAddress); } if (action.payload.setPrimary) { @@ -222,13 +223,12 @@ function* connectToDeviceWorker( } } -function* disconnectFromDeviceWorker( +async function* disconnectFromDeviceWorker( action: ReturnType, ) { try { - yield call(invoke, "drop_device_connection", { - deviceKey: action.payload, - }); + await connectionApi.dropDeviceConnection(action.payload); + yield put(deviceSliceActions.setPrimaryDeviceConnectionKey(null)); yield put(uiSliceActions.setActiveNode(null)); yield put(deviceSliceActions.setDevice(null)); @@ -237,9 +237,10 @@ function* disconnectFromDeviceWorker( } } -function* disconnectFromAllDevicesWorker() { +async function* disconnectFromAllDevicesWorker() { try { - yield call(invoke, "drop_all_device_connections"); + await connectionApi.dropAllDeviceConnections(); + yield put(deviceSliceActions.setPrimaryDeviceConnectionKey(null)); yield put(uiSliceActions.setActiveNode(null)); yield put(deviceSliceActions.setDevice(null)); @@ -248,15 +249,15 @@ function* disconnectFromAllDevicesWorker() { } } -function* sendTextWorker(action: ReturnType) { +async function* sendTextWorker(action: ReturnType) { try { yield put(requestSliceActions.setRequestPending({ name: action.type })); - yield call(invoke, "send_text", { - deviceKey: action.payload.deviceKey, - channel: action.payload.channel, - text: action.payload.text, - }); + await meshApi.sendText( + action.payload.deviceKey, + action.payload.channel, + action.payload.text, + ); yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); } catch (error) { @@ -273,10 +274,7 @@ function* updateUserConfigWorker(action: ReturnType) { try { yield put(requestSliceActions.setRequestPending({ name: action.type })); - yield call(invoke, "update_device_user", { - deviceKey: action.payload.deviceKey, - user: action.payload.user, - }); + radioApi.updateDeviceUser(action.payload.deviceKey, action.payload.user); yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); } catch (error) { @@ -289,15 +287,17 @@ function* updateUserConfigWorker(action: ReturnType) { } } -function* sendWaypointWorker(action: ReturnType) { +async function* sendWaypointWorker( + action: ReturnType, +) { try { yield put(requestSliceActions.setRequestPending({ name: action.type })); - yield call(invoke, "send_waypoint", { - deviceKey: action.payload.deviceKey, - channel: action.payload.channel, - waypoint: action.payload.waypoint, - }); + await meshApi.sendWaypoint( + action.payload.deviceKey, + action.payload.channel, + action.payload.waypoint, + ); yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); } catch (error) { @@ -312,16 +312,16 @@ function* sendWaypointWorker(action: ReturnType) { } } -function* deleteWaypointWorker( +async function* deleteWaypointWorker( action: ReturnType, ) { try { yield put(requestSliceActions.setRequestPending({ name: action.type })); - yield call(invoke, "delete_waypoint", { - deviceKey: action.payload.deviceKey, - waypointId: action.payload.waypointId, - }); + await meshApi.deleteWaypoint( + action.payload.deviceKey, + action.payload.waypointId, + ); yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); } catch (error) { diff --git a/src/store/index.ts b/src/store/index.ts index 79e07bb3..7bf7c8d5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,7 +4,6 @@ import createSagaMiddleware from "redux-saga"; import { requestInitializeApplication } from "@features/device/actions"; -import { algorithmsReducer } from "@features/algorithms/slice"; import { appConfigReducer } from "@features/appConfig/slice"; import { configReducer } from "@features/config/slice"; import { connectionReducer } from "@features/connection/slice"; @@ -20,7 +19,6 @@ const middleware = [sagaMiddleware, logger]; export const store = configureStore({ reducer: { - algorithms: algorithmsReducer, appConfig: appConfigReducer, config: configReducer, connection: connectionReducer, diff --git a/src/store/saga.ts b/src/store/saga.ts index 033ba0f3..ceb0e36b 100644 --- a/src/store/saga.ts +++ b/src/store/saga.ts @@ -1,15 +1,9 @@ import { all, fork } from "redux-saga/effects"; -import { algorithmsSaga } from "@features/algorithms/sagas"; import { appConfigSaga } from "@features/appConfig/sagas"; import { configSaga } from "@features/config/sagas"; import { devicesSaga } from "@features/device/sagas"; export function* rootSaga() { - yield all([ - fork(algorithmsSaga), - fork(appConfigSaga), - fork(configSaga), - fork(devicesSaga), - ]); + yield all([fork(appConfigSaga), fork(configSaga), fork(devicesSaga)]); } diff --git a/tsconfig.json b/tsconfig.json index f7e0b2df..d64f749f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,9 @@ { - "include": [ - "src", - "types" - ], + "include": ["src", "types"], "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, - "lib": [ - "DOM", - "DOM.Iterable", - "ESNext" - ], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, @@ -25,34 +18,19 @@ "jsx": "react-jsx", "baseUrl": "./", "paths": { - "@bindings/*": [ - "src/bindings/*" - ], - "@components/*": [ - "src/components/*" - ], - "@features/*": [ - "src/features/*" - ], - "@store/*": [ - "src/store/*" - ], - "@utils/*": [ - "src/utils/*" - ], - "@app/*": [ - "src/*" - ], + "@api/*": ["src/api/*"], + "@bindings/*": ["src/bindings/*"], + "@components/*": ["src/components/*"], + "@features/*": ["src/features/*"], + "@store/*": ["src/store/*"], + "@utils/*": ["src/utils/*"], + "@app/*": ["src/*"] }, "importHelpers": true, "removeComments": true, "strictNullChecks": true, - "types": [ - "vite/client", - "node", - "jest" - ], + "types": ["vite/client", "node", "jest"], "strictPropertyInitialization": false, "experimentalDecorators": true } -} \ No newline at end of file +} diff --git a/vite.config.ts b/vite.config.ts index 0a646f3d..002710a2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ resolve: { alias: { "@app": resolve(__dirname, "./src"), + "@api": resolve(__dirname, "./src/api"), "@bindings": resolve(__dirname, "./src/bindings"), "@components": resolve(__dirname, "./src/components"), "@features": resolve(__dirname, "./src/features"), From d87ceb8b216eeb2e93e311d0a3fe45913004bbe1 Mon Sep 17 00:00:00 2001 From: Adam McQuilkin <46639306+ajmcquilkin@users.noreply.github.com> Date: Mon, 19 Feb 2024 16:15:19 -0800 Subject: [PATCH 2/3] Removed redux saga --- package.json | 1 - pnpm-lock.yaml | 63 ---- src/api/{backend => }/connection.ts | 0 src/api/events.ts | 100 +++++ src/api/{backend => }/mesh.ts | 0 src/api/{backend => }/radio.ts | 1 + src/components/AppInitWrapper.tsx | 43 +++ src/components/Map/CreateWaypointDialog.tsx | 16 +- .../Messaging/ChannelDetailView.tsx | 16 +- src/components/Waypoints/WaypointMenu.tsx | 14 +- .../config/application/GeneralConfigPage.tsx | 6 +- .../config/application/MapConfigPage.tsx | 6 +- src/components/pages/ConnectPage.tsx | 65 ++-- .../pages/config/ChannelConfigPage.tsx | 6 +- .../pages/config/ModuleConfigPage.tsx | 6 +- .../pages/config/RadioConfigPage.tsx | 6 +- src/features/appConfig/actions.ts | 23 -- src/features/appConfig/api.ts | 137 +++++++ src/features/appConfig/sagas.ts | 234 ------------ src/features/config/actions.ts | 6 - src/features/config/api.ts | 135 +++++++ src/features/config/sagas.ts | 159 -------- src/features/device/actions.ts | 62 --- src/features/device/api.ts | 243 ++++++++++++ src/features/device/connectionHandlerSagas.ts | 149 -------- src/features/device/sagas.ts | 353 ------------------ src/main.tsx | 9 +- src/store/index.ts | 12 +- src/store/persistence.ts | 4 + src/store/saga.ts | 9 - src/utils/api.ts | 57 +++ src/utils/errors.ts | 6 +- src/utils/hooks.ts | 4 +- src/utils/persistence.ts | 35 +- src/utils/ui.ts | 39 ++ 35 files changed, 870 insertions(+), 1155 deletions(-) rename src/api/{backend => }/connection.ts (100%) create mode 100644 src/api/events.ts rename src/api/{backend => }/mesh.ts (100%) rename src/api/{backend => }/radio.ts (99%) create mode 100644 src/components/AppInitWrapper.tsx delete mode 100644 src/features/appConfig/actions.ts create mode 100644 src/features/appConfig/api.ts delete mode 100644 src/features/appConfig/sagas.ts delete mode 100644 src/features/config/actions.ts create mode 100644 src/features/config/api.ts delete mode 100644 src/features/config/sagas.ts delete mode 100644 src/features/device/actions.ts create mode 100644 src/features/device/api.ts delete mode 100644 src/features/device/connectionHandlerSagas.ts delete mode 100644 src/features/device/sagas.ts create mode 100644 src/store/persistence.ts delete mode 100644 src/store/saga.ts create mode 100644 src/utils/api.ts create mode 100644 src/utils/ui.ts diff --git a/package.json b/package.json index 15280c9d..126e8d19 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "react-tsparticles": "^2.7.1", "react-use": "^17.4.0", "redux-logger": "^3.0.6", - "redux-saga": "^1.2.1", "tailwindcss-radix": "^2.8.0", "tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1", "timeago-react": "^3.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbb764c6..2026d0ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,9 +155,6 @@ dependencies: redux-logger: specifier: ^3.0.6 version: 3.0.6 - redux-saga: - specifier: ^1.2.1 - version: 1.2.1 tailwindcss-radix: specifier: ^2.8.0 version: 2.8.0 @@ -2960,44 +2957,6 @@ packages: '@babel/runtime': 7.21.0 dev: false - /@redux-saga/core@1.2.1: - resolution: {integrity: sha512-ABCxsZy9DwmNoYNo54ZlfuTvh77RXx8ODKpxOHeWam2dOaLGQ7vAktpfOtqSeTdYrKEORtTeWnxkGJMmPOoukg==} - dependencies: - '@babel/runtime': 7.21.0 - '@redux-saga/deferred': 1.2.1 - '@redux-saga/delay-p': 1.2.1 - '@redux-saga/is': 1.1.3 - '@redux-saga/symbols': 1.1.3 - '@redux-saga/types': 1.2.1 - redux: 4.2.0 - typescript-tuple: 2.2.1 - dev: false - - /@redux-saga/deferred@1.2.1: - resolution: {integrity: sha512-cmin3IuuzMdfQjA0lG4B+jX+9HdTgHZZ+6u3jRAOwGUxy77GSlTi4Qp2d6PM1PUoTmQUR5aijlA39scWWPF31g==} - dev: false - - /@redux-saga/delay-p@1.2.1: - resolution: {integrity: sha512-MdiDxZdvb1m+Y0s4/hgdcAXntpUytr9g0hpcOO1XFVyyzkrDu3SKPgBFOtHn7lhu7n24ZKIAT1qtKyQjHqRd+w==} - dependencies: - '@redux-saga/symbols': 1.1.3 - dev: false - - /@redux-saga/is@1.1.3: - resolution: {integrity: sha512-naXrkETG1jLRfVfhOx/ZdLj0EyAzHYbgJWkXbB3qFliPcHKiWbv/ULQryOAEKyjrhiclmr6AMdgsXFyx7/yE6Q==} - dependencies: - '@redux-saga/symbols': 1.1.3 - '@redux-saga/types': 1.2.1 - dev: false - - /@redux-saga/symbols@1.1.3: - resolution: {integrity: sha512-hCx6ZvU4QAEUojETnX8EVg4ubNLBFl1Lps4j2tX7o45x/2qg37m3c6v+kSp8xjDJY+2tJw4QB3j8o8dsl1FDXg==} - dev: false - - /@redux-saga/types@1.2.1: - resolution: {integrity: sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==} - dev: false - /@reduxjs/toolkit@1.8.6(react-redux@8.0.4)(react@18.2.0): resolution: {integrity: sha512-4Ia/Loc6WLmdSOzi7k5ff7dLK8CgG2b8aqpLsCAJhazAzGdp//YBUSaj0ceW6a3kDBDNRrq5CRwyCS0wBiL1ig==} peerDependencies: @@ -6008,12 +5967,6 @@ packages: deep-diff: 0.3.8 dev: false - /redux-saga@1.2.1: - resolution: {integrity: sha512-fVCicLlf4hLP+KB6H7RHfZlZ8LdYckhaemXBB3wh//a2ESyz/z/l8ygxlm0OqPjS/PARdsQ2hIdAltxEB+NgvA==} - dependencies: - '@redux-saga/core': 1.2.1 - dev: false - /redux-thunk@2.4.1(redux@4.2.0): resolution: {integrity: sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==} peerDependencies: @@ -6759,22 +6712,6 @@ packages: engines: {node: '>=10'} dev: true - /typescript-compare@0.0.2: - resolution: {integrity: sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==} - dependencies: - typescript-logic: 0.0.0 - dev: false - - /typescript-logic@0.0.0: - resolution: {integrity: sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==} - dev: false - - /typescript-tuple@2.2.1: - resolution: {integrity: sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==} - dependencies: - typescript-compare: 0.0.2 - dev: false - /typescript@4.8.4: resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==} engines: {node: '>=4.2.0'} diff --git a/src/api/backend/connection.ts b/src/api/connection.ts similarity index 100% rename from src/api/backend/connection.ts rename to src/api/connection.ts diff --git a/src/api/events.ts b/src/api/events.ts new file mode 100644 index 00000000..ad774e04 --- /dev/null +++ b/src/api/events.ts @@ -0,0 +1,100 @@ +import { listen } from "@tauri-apps/api/event"; +import { useDispatch } from "react-redux"; + +import { app_device_MeshDevice } from "@bindings/index"; + +import { connectionSliceActions } from "@features/connection/slice"; +import { deviceSliceActions } from "@features/device/slice"; +import { useDeviceApi } from "@features/device/api"; + +import { DeviceKey } from "@utils/connections"; + +export const useCreateDeviceUpdateChannel = () => { + const dispatch = useDispatch(); + + const createChannel = async () => { + const unlisten = await listen( + "device_update", + (event) => { + const updatedDevice = event.payload; + dispatch(deviceSliceActions.setDevice(updatedDevice)); + }, + ); + + return unlisten; + }; + + return createChannel; +}; + +export const useCreateDeviceDisconnectChannel = () => { + const deviceApi = useDeviceApi(); + + const createChannel = async () => { + const unlisten = await listen("device_disconnect", (event) => { + const deviceKey = event.payload; + deviceApi.disconnectFromDevice(deviceKey); + window.location.reload(); + }); + + return unlisten; + }; + + return createChannel; +}; + +export const useCreateConfigStatusChannel = () => { + const dispatch = useDispatch(); + const deviceApi = useDeviceApi(); + + const createChannel = async () => { + const unlisten = await listen<{ + deviceKey: DeviceKey; + successful: boolean; + message: string | null; + }>("configuration_status", (event) => { + const { + successful, + deviceKey, + message, + }: { + successful: boolean; + deviceKey: DeviceKey; + message: string | null; + } = event.payload; + + if (!successful) { + deviceApi.disconnectFromDevice(deviceKey); + } + + dispatch( + connectionSliceActions.setConnectionState({ + deviceKey: deviceKey, + status: successful + ? { status: "SUCCESSFUL" } + : { status: "FAILED", message: message ?? "" }, + }), + ); + }); + + return unlisten; + }; + + return createChannel; +}; + +export const useCreateRebootChannel = () => { + const createChannel = async () => { + const unlisten = await listen("reboot", (event) => { + const rebootTimestampSec = event.payload; + + const reboot_time = new Date(rebootTimestampSec * 1000); + console.warn("Rebooting at", reboot_time); + window.location.reload(); + }); + + return unlisten; + }; + + return createChannel; +}; diff --git a/src/api/backend/mesh.ts b/src/api/mesh.ts similarity index 100% rename from src/api/backend/mesh.ts rename to src/api/mesh.ts diff --git a/src/api/backend/radio.ts b/src/api/radio.ts similarity index 99% rename from src/api/backend/radio.ts rename to src/api/radio.ts index f167d385..df6c3372 100644 --- a/src/api/backend/radio.ts +++ b/src/api/radio.ts @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api"; + import { app_ipc_DeviceBulkConfig, meshtastic_protobufs_Config, diff --git a/src/components/AppInitWrapper.tsx b/src/components/AppInitWrapper.tsx new file mode 100644 index 00000000..5d15d17c --- /dev/null +++ b/src/components/AppInitWrapper.tsx @@ -0,0 +1,43 @@ +import { PropsWithChildren, useEffect, useState } from "react"; + +import { + useCreateConfigStatusChannel, + useCreateDeviceDisconnectChannel, + useCreateDeviceUpdateChannel, + useCreateRebootChannel, +} from "@api/events"; +import { useAppConfigApi } from "@features/appConfig/api"; +import { useAsyncUnlistenUseEffect } from "@utils/ui"; + +export const AppInitWrapper = ({ children }: PropsWithChildren) => { + const appConfigApi = useAppConfigApi(); + + const createDeviceUpdateChannel = useCreateDeviceUpdateChannel(); + const createDeviceDisconnectChannel = useCreateDeviceDisconnectChannel(); + const createConfigStatusChannel = useCreateConfigStatusChannel(); + const createRebootChannel = useCreateRebootChannel(); + + const [hasLoaded, setLoaded] = useState(false); + + useEffect(() => { + appConfigApi.initializeAppConfig(); + }, []); + + useAsyncUnlistenUseEffect(async () => { + const unlistenDeviceUpdate = await createDeviceUpdateChannel(); + const unlistenDeviceDisconnect = await createDeviceDisconnectChannel(); + const unlistenConfigStatus = await createConfigStatusChannel(); + const unlistenReboot = await createRebootChannel(); + + setLoaded(true); + + return () => { + unlistenDeviceUpdate(); + unlistenDeviceDisconnect(); + unlistenConfigStatus(); + unlistenReboot(); + }; + }, []); + + return
{hasLoaded ? children : null}
; +}; diff --git a/src/components/Map/CreateWaypointDialog.tsx b/src/components/Map/CreateWaypointDialog.tsx index 5cf4c559..3ffa22ed 100644 --- a/src/components/Map/CreateWaypointDialog.tsx +++ b/src/components/Map/CreateWaypointDialog.tsx @@ -34,7 +34,7 @@ import { ConnectionInput } from "@components/connection/ConnectionInput"; import { ConnectionSwitch } from "@components/connection/ConnectionSwitch"; import { selectMapConfigState } from "@features/appConfig/selectors"; -import { requestSendWaypoint } from "@features/device/actions"; +import { useDeviceApi } from "@features/device/api"; import { selectDevice, selectDeviceChannels, @@ -82,6 +82,8 @@ export const CreateWaypointDialog = ({ const { style } = useSelector(selectMapConfigState()); const device = useSelector(selectDevice()); + const deviceApi = useDeviceApi(); + const { [MapIDs.CreateWaypointDialog]: map } = useMap(); const [name, setName] = useState<{ value: string; isValid: boolean }>( @@ -227,13 +229,11 @@ export const CreateWaypointDialog = ({ return; } - dispatch( - requestSendWaypoint({ - deviceKey: primaryDeviceKey, - waypoint: createdWaypoint, - channel: channelNum, - }), - ); + deviceApi.sendWaypoint({ + deviceKey: primaryDeviceKey, + waypoint: createdWaypoint, + channel: channelNum, + }); closeDialog(); }; diff --git a/src/components/Messaging/ChannelDetailView.tsx b/src/components/Messaging/ChannelDetailView.tsx index c2544152..9399551b 100644 --- a/src/components/Messaging/ChannelDetailView.tsx +++ b/src/components/Messaging/ChannelDetailView.tsx @@ -8,7 +8,7 @@ import { MessagingInput } from "@components/Messaging/MessagingInput"; import { TextMessageBubble } from "@components/Messaging/TextMessageBubble"; import { ConfigTitlebar } from "@components/config/ConfigTitlebar"; -import { requestSendMessage } from "@features/device/actions"; +import { useDeviceApi } from "@features/device/api"; import { selectPrimaryDeviceKey } from "@features/device/selectors"; import { getChannelName, getNumMessagesText } from "@utils/messaging"; @@ -26,6 +26,8 @@ export const ChannelDetailView = ({ const dispatch = useDispatch(); const primaryDeviceKey = useSelector(selectPrimaryDeviceKey()); + const deviceApi = useDeviceApi(); + const navigateTo = useNavigate(); const handleMessageSubmit = (message: string) => { @@ -39,13 +41,11 @@ export const ChannelDetailView = ({ return; } - dispatch( - requestSendMessage({ - deviceKey: primaryDeviceKey, - text: message, - channel: channel.config.index, - }), - ); + deviceApi.sendText({ + deviceKey: primaryDeviceKey, + text: message, + channel: channel.config.index, + }); }; return ( diff --git a/src/components/Waypoints/WaypointMenu.tsx b/src/components/Waypoints/WaypointMenu.tsx index 37aa9b42..7c5f084d 100644 --- a/src/components/Waypoints/WaypointMenu.tsx +++ b/src/components/Waypoints/WaypointMenu.tsx @@ -5,7 +5,7 @@ import { useDispatch, useSelector } from "react-redux"; import type { app_device_NormalizedWaypoint } from "@bindings/index"; -import { requestDeleteWaypoint } from "@features/device/actions"; +import { useDeviceApi } from "@features/device/api"; import { selectAllUsersByNodeIds, selectDevice, @@ -34,6 +34,8 @@ export const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => { const device = useSelector(selectDevice()); const usersMap = useSelector(selectAllUsersByNodeIds()); + const deviceApi = useDeviceApi(); + // Only show if there is an active waypoint if (!activeWaypoint) return null; @@ -52,12 +54,10 @@ export const WaypointMenu = ({ editWaypoint }: IWaypointMenuProps) => { return; } - dispatch( - requestDeleteWaypoint({ - deviceKey: primaryDeviceKey, - waypointId: activeWaypoint.id, - }), - ); + deviceApi.deleteWaypoint({ + deviceKey: primaryDeviceKey, + waypointId: activeWaypoint.id, + }); }; const { description, latitude, longitude, expire, icon, lockedTo } = diff --git a/src/components/config/application/GeneralConfigPage.tsx b/src/components/config/application/GeneralConfigPage.tsx index a6f164f6..44cfec87 100644 --- a/src/components/config/application/GeneralConfigPage.tsx +++ b/src/components/config/application/GeneralConfigPage.tsx @@ -10,9 +10,9 @@ import { v4 } from "uuid"; import { ConfigSelect } from "@components/config/ConfigSelect"; import { ConfigTitlebar } from "@components/config/ConfigTitlebar"; -import { requestPersistGeneralConfig } from "@features/appConfig/actions"; import { selectGeneralConfigState } from "@features/appConfig/selectors"; import type { ColorMode } from "@features/appConfig/slice"; +import { useAppConfigApi } from "@features/appConfig/api"; export interface IGeneralConfigPageProps { className?: string; @@ -30,6 +30,8 @@ export const GeneralConfigPage = ({ const dispatch = useDispatch(); const { colorMode } = useSelector(selectGeneralConfigState()); + const appConfigApi = useAppConfigApi(); + const { register, handleSubmit, @@ -37,7 +39,7 @@ export const GeneralConfigPage = ({ } = useForm({ defaultValues: { colorMode } }); const handleSubmitSuccess = (data: GeneralConfigFormInput) => { - dispatch(requestPersistGeneralConfig({ colorMode: data.colorMode })); + appConfigApi.persistGeneralConfig({ colorMode: data.colorMode }); }; const handleFormSubmit: FormEventHandler = (e) => { diff --git a/src/components/config/application/MapConfigPage.tsx b/src/components/config/application/MapConfigPage.tsx index a3ad47a0..7ec16b56 100644 --- a/src/components/config/application/MapConfigPage.tsx +++ b/src/components/config/application/MapConfigPage.tsx @@ -9,8 +9,8 @@ import { v4 } from "uuid"; import { ConfigInput } from "@components/config/ConfigInput"; import { ConfigTitlebar } from "@components/config/ConfigTitlebar"; -import { requestPersistMapConfig } from "@features/appConfig/actions"; import { selectMapConfigState } from "@features/appConfig/selectors"; +import { useAppConfigApi } from "@features/appConfig/api"; export interface IMapConfigPageProps { className?: string; @@ -26,6 +26,8 @@ export const MapConfigPage = ({ className = "" }: IMapConfigPageProps) => { const dispatch = useDispatch(); const { style } = useSelector(selectMapConfigState()); + const appConfigApi = useAppConfigApi(); + const { register, handleSubmit, @@ -33,7 +35,7 @@ export const MapConfigPage = ({ className = "" }: IMapConfigPageProps) => { } = useForm({ defaultValues: { style } }); const handleSubmitSuccess = (data: MapConfigFormInput) => { - dispatch(requestPersistMapConfig({ style: data.style })); + appConfigApi.persistMapConfig({ style: data.style }); }; const handleFormSubmit: FormEventHandler = (e) => { diff --git a/src/components/pages/ConnectPage.tsx b/src/components/pages/ConnectPage.tsx index 233d3b77..9d81d28f 100644 --- a/src/components/pages/ConnectPage.tsx +++ b/src/components/pages/ConnectPage.tsx @@ -12,19 +12,11 @@ import { ConnectTab } from "@components/connection/ConnectTab"; import { SerialConnectPane } from "@components/connection/SerialConnectPane"; import { TcpConnectPane } from "@components/connection/TcpConnectPane"; -import { - requestFetchLastTcpConnectionMeta, - requestPersistLastTcpConnectionMeta, -} from "@features/appConfig/actions"; +import { useAppConfigApi } from "@features/appConfig/api"; import { selectPersistedTCPConnectionMeta } from "@features/appConfig/selectors"; import { selectConnectionStatus } from "@features/connection/selectors"; import { connectionSliceActions } from "@features/connection/slice"; -import { - requestAutoConnectPort, - requestAvailablePorts, - requestConnectToDevice, - requestDisconnectFromAllDevices, -} from "@features/device/actions"; +import { DeviceApiActions, useDeviceApi } from "@features/device/api"; import { selectAutoConnectPort, selectAvailablePorts, @@ -46,6 +38,9 @@ export interface IOnboardPageProps { export const ConnectPage = ({ unmountSelf }: IOnboardPageProps) => { const { t } = useTranslation(); + const appConfigApi = useAppConfigApi(); + const deviceApi = useDeviceApi(); + const { isDarkMode } = useIsDarkMode(); const dispatch = useDispatch(); @@ -84,34 +79,30 @@ export const ConnectPage = ({ unmountSelf }: IOnboardPageProps) => { ); const requestPorts = useCallback(() => { - dispatch(requestAvailablePorts()); + deviceApi.getAvailableSerialPorts(); }, [dispatch]); const handleSocketConnect: FormEventHandler = (e) => { e.preventDefault(); - dispatch( - requestPersistLastTcpConnectionMeta({ - address: socketAddress, - port: parseInt(socketPort), - }), - ); - - dispatch( - requestConnectToDevice({ - params: { - type: ConnectionType.TCP, - socketAddress: getFullSocketAddress(socketAddress, socketPort), - }, - setPrimary: true, - }), - ); + appConfigApi.persistLastTcpConnectionMeta({ + address: socketAddress, + port: parseInt(socketPort), + }); + + deviceApi.connectToDevice({ + params: { + type: ConnectionType.TCP, + socketAddress: getFullSocketAddress(socketAddress, socketPort), + }, + setPrimary: true, + }); }; const refreshPorts = () => { dispatch( requestSliceActions.clearRequestState({ - name: requestConnectToDevice.type, + name: DeviceApiActions.ConnectToDevice, }), ); dispatch(connectionSliceActions.clearAllConnectionState()); @@ -120,12 +111,10 @@ export const ConnectPage = ({ unmountSelf }: IOnboardPageProps) => { const handlePortSelected = (portName: string) => { setSelectedPortName(portName); - dispatch( - requestConnectToDevice({ - params: { type: ConnectionType.SERIAL, portName, dtr, rts }, - setPrimary: true, - }), - ); + deviceApi.connectToDevice({ + params: { type: ConnectionType.SERIAL, portName, dtr, rts }, + setPrimary: true, + }); }; const openExternalLink = (url: string) => () => { @@ -133,9 +122,11 @@ export const ConnectPage = ({ unmountSelf }: IOnboardPageProps) => { }; useEffect(() => { - dispatch(requestDisconnectFromAllDevices()); - dispatch(requestAutoConnectPort()); - dispatch(requestFetchLastTcpConnectionMeta()); + deviceApi.disconnectFromAllDevices(); + deviceApi.getAutoConnectPort(); + + appConfigApi.fetchLastTcpConnectionMeta(); + requestPorts(); }, [dispatch, requestPorts]); diff --git a/src/components/pages/config/ChannelConfigPage.tsx b/src/components/pages/config/ChannelConfigPage.tsx index 20dab0c2..8a6ef5a1 100644 --- a/src/components/pages/config/ChannelConfigPage.tsx +++ b/src/components/pages/config/ChannelConfigPage.tsx @@ -8,7 +8,7 @@ import { ConfigLayout } from "@components/config/ConfigLayout"; import { ConfigOption } from "@components/config/ConfigOption"; import { ChannelConfigDetail } from "@components/config/channel/ChannelConfigDetail"; -import { requestCommitConfig } from "@features/config/actions"; +import { useConfigApi } from "@features/config/api"; import { selectEditedAllChannelConfig } from "@features/config/selectors"; import type { ChannelConfigInput } from "@features/config/slice"; import { selectDeviceChannels } from "@features/device/selectors"; @@ -55,6 +55,8 @@ export const ChannelConfigPage = () => { const dispatch = useDispatch(); const meshChannels = useSelector(selectDeviceChannels()); + const configApi = useConfigApi(); + const { channelId } = useParams(); const currentChannelConfig = useMemo( @@ -82,7 +84,7 @@ export const ChannelConfigPage = () => { backtrace={[t("sidebar.configureChannels")]} renderTitleIcon={(c) => } titleIconTooltip={t("config.uploadChanges")} - onTitleIconClick={() => dispatch(requestCommitConfig(["channel"]))} + onTitleIconClick={() => configApi.commitConfig(["channel"])} renderOptions={() => meshChannels.map((c) => { const displayName = getChannelName(c); diff --git a/src/components/pages/config/ModuleConfigPage.tsx b/src/components/pages/config/ModuleConfigPage.tsx index a0e700b0..46a82630 100644 --- a/src/components/pages/config/ModuleConfigPage.tsx +++ b/src/components/pages/config/ModuleConfigPage.tsx @@ -20,7 +20,7 @@ import { SerialModuleConfigPage } from "@components/config/module/SerialModuleCo import { StoreAndForwardConfigPage } from "@components/config/module/StoreAndForwardConfigPage"; import { TelemetryConfigPage } from "@components/config/module/TelemetryConfigPage"; -import { requestCommitConfig } from "@features/config/actions"; +import { useConfigApi } from "@features/config/api"; import { selectCurrentModuleConfig, selectEditedModuleConfig, @@ -118,6 +118,8 @@ export const ModuleConfigPage = () => { const currentModuleConfig = useSelector(selectCurrentModuleConfig()); const editedModuleConfig = useSelector(selectEditedModuleConfig()); + const configApi = useConfigApi(); + const { configKey } = useParams(); const [activeOption, setActiveOption] = @@ -135,7 +137,7 @@ export const ModuleConfigPage = () => { backtrace={[t("sidebar.configureModules")]} renderTitleIcon={(c) => } titleIconTooltip={t("config.uploadChanges")} - onTitleIconClick={() => dispatch(requestCommitConfig(["module"]))} + onTitleIconClick={() => configApi.commitConfig(["module"])} renderOptions={() => Object.entries(ModuleConfigOptions).map(([k, displayName]) => { // * This is a limitation of Object.entries typing diff --git a/src/components/pages/config/RadioConfigPage.tsx b/src/components/pages/config/RadioConfigPage.tsx index fa921bb8..b15a6aee 100644 --- a/src/components/pages/config/RadioConfigPage.tsx +++ b/src/components/pages/config/RadioConfigPage.tsx @@ -18,7 +18,7 @@ import { NetworkConfigPage } from "@components/config/device/NetworkConfigPage"; import { PositionConfigPage } from "@components/config/device/PositionConfigPage"; import { PowerConfigPage } from "@components/config/device/PowerConfigPage"; -import { requestCommitConfig } from "@features/config/actions"; +import { useConfigApi } from "@features/config/api"; import { selectCurrentRadioConfig, selectEditedRadioConfig, @@ -110,6 +110,8 @@ export const RadioConfigPage = () => { const currentRadioConfig = useSelector(selectCurrentRadioConfig()); const editedRadioConfig = useSelector(selectEditedRadioConfig()); + const configApi = useConfigApi(); + const { configKey } = useParams(); const [activeOption, setActiveOption] = @@ -127,7 +129,7 @@ export const RadioConfigPage = () => { backtrace={[t("sidebar.configureRadio")]} renderTitleIcon={(c) => } titleIconTooltip={t("config.uploadChanges")} - onTitleIconClick={() => dispatch(requestCommitConfig(["radio"]))} + onTitleIconClick={() => configApi.commitConfig(["radio"])} renderOptions={() => Object.entries(RadioConfigOptions).map(([k, displayName]) => { // * This is a limitation of Object.entries typing diff --git a/src/features/appConfig/actions.ts b/src/features/appConfig/actions.ts deleted file mode 100644 index 0d621f25..00000000 --- a/src/features/appConfig/actions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { - IGeneralConfigState, - IMapConfigState, - TcpConnectionMeta, -} from "@features/appConfig/slice"; -import { createAction } from "@reduxjs/toolkit"; - -export const requestFetchLastTcpConnectionMeta = createAction( - "@appConfig/fetch-last-tcp-connection-meta", -); - -export const requestPersistLastTcpConnectionMeta = - createAction( - "@appConfig/persist-last-tcp-connection-meta", - ); - -export const requestPersistGeneralConfig = createAction( - "@appConfig/persist-general-config", -); - -export const requestPersistMapConfig = createAction( - "@appConfig/persist-map-config", -); diff --git a/src/features/appConfig/api.ts b/src/features/appConfig/api.ts new file mode 100644 index 00000000..d9ef8ec0 --- /dev/null +++ b/src/features/appConfig/api.ts @@ -0,0 +1,137 @@ +import { useDispatch } from "react-redux"; + +import { defaultStore } from "@store/persistence"; + +import { + PersistedStateKeys, + setAndValidateValueInPersistedStore, + getValueFromPersistedStore, +} from "@utils/persistence"; +import { + IGeneralConfigState, + IMapConfigState, + TcpConnectionMeta, + appConfigSliceActions, +} from "./slice"; +import { setColorModeClass } from "@utils/ui"; +import { trackRequestOperation } from "@utils/api"; + +export enum AppConfigApiActions { + FetchLastTcpConnectionMeta = "appConfig/fetchLastTcpConnectionMeta", + PersistLastTcpConnectionMeta = "appConfig/persistLastTcpConnectionMeta", + PersistGeneralConfig = "appConfig/persistGeneralConfig", + PersistMapConfig = "appConfig/persistMapConfig", + InitializeAppConfig = "appConfig/initializeAppConfig", +} + +export const useAppConfigApi = () => { + const dispatch = useDispatch(); + + // TODO can probably be integrated into general initialization + const fetchLastTcpConnectionMeta = async () => { + const TYPE = AppConfigApiActions.FetchLastTcpConnectionMeta; + + await trackRequestOperation(TYPE, dispatch, async () => { + const persistedValue = await getValueFromPersistedStore( + defaultStore, + PersistedStateKeys.LastTcpConnection, + ); + + dispatch( + appConfigSliceActions.setLastTcpConnection(persistedValue ?? null), + ); + }); + }; + + const persistLastTcpConnectionMeta = async ( + payload: TcpConnectionMeta | null, + ) => { + const TYPE = AppConfigApiActions.PersistLastTcpConnectionMeta; + + await trackRequestOperation(TYPE, dispatch, async () => { + await setAndValidateValueInPersistedStore( + defaultStore, + PersistedStateKeys.LastTcpConnection, + payload ?? undefined, + true, + "Failed to persist last TCP connection", + ); + }); + }; + + const _updateGeneralConfig = (generalConfig: IGeneralConfigState) => { + setColorModeClass(generalConfig.colorMode); + dispatch(appConfigSliceActions.updateGeneralConfig(generalConfig)); + }; + + const persistGeneralConfig = async (generalConfig: IGeneralConfigState) => { + const TYPE = AppConfigApiActions.PersistGeneralConfig; + + await trackRequestOperation(TYPE, dispatch, async () => { + await setAndValidateValueInPersistedStore( + defaultStore, + PersistedStateKeys.GeneralConfig, + generalConfig, + true, + "Failed to persist general application configuration", + ); + + _updateGeneralConfig(generalConfig); + }); + }; + + const _updateMapConfig = (mapConfig: IMapConfigState) => { + dispatch(appConfigSliceActions.updateMapConfig(mapConfig)); + }; + + const persistMapConfig = async (mapConfig: IMapConfigState) => { + const TYPE = AppConfigApiActions.PersistMapConfig; + + await trackRequestOperation(TYPE, dispatch, async () => { + await setAndValidateValueInPersistedStore( + defaultStore, + PersistedStateKeys.MapConfig, + mapConfig, + true, + "Failed to persist map configuration", + ); + + _updateMapConfig(mapConfig); + }); + }; + + const initializeAppConfig = async () => { + const TYPE = AppConfigApiActions.InitializeAppConfig; + + await trackRequestOperation(TYPE, dispatch, async () => { + const persistedGeneralConfig = await getValueFromPersistedStore( + defaultStore, + PersistedStateKeys.GeneralConfig, + ); + + if (persistedGeneralConfig) { + _updateGeneralConfig(persistedGeneralConfig); + } else { + // Update color mode to system default if not persisted + setColorModeClass("system"); + } + + const persistedMapConfig = await getValueFromPersistedStore( + defaultStore, + PersistedStateKeys.MapConfig, + ); + + if (persistedMapConfig) { + _updateMapConfig(persistedMapConfig); + } + }); + }; + + return { + fetchLastTcpConnectionMeta, + persistLastTcpConnectionMeta, + persistGeneralConfig, + persistMapConfig, + initializeAppConfig, + }; +}; diff --git a/src/features/appConfig/sagas.ts b/src/features/appConfig/sagas.ts deleted file mode 100644 index 0cc3986d..00000000 --- a/src/features/appConfig/sagas.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { all, call, put, takeEvery } from "redux-saga/effects"; -import { Store } from "tauri-plugin-store-api"; - -import { - requestFetchLastTcpConnectionMeta, - requestPersistGeneralConfig, - requestPersistLastTcpConnectionMeta, - requestPersistMapConfig, -} from "@features/appConfig/actions"; -import { ColorMode, appConfigSliceActions } from "@features/appConfig/slice"; -import { requestInitializeApplication } from "@features/device/actions"; -import { requestSliceActions } from "@features/requests/slice"; - -import type { CommandError } from "@utils/errors"; -import { - DEFAULT_STORE_FILE_NAME, - IPersistedState, - PersistedStateKeys, - getValueFromPersistedStore, - setValueInPersistedStore, -} from "@utils/persistence"; - -const defaultStore = new Store(DEFAULT_STORE_FILE_NAME); - -function* fetchLastTcpConnectionMetaWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - const persistedValue = (yield getValueFromPersistedStore( - defaultStore, - PersistedStateKeys.LastTcpConnection, - )) as IPersistedState[PersistedStateKeys.LastTcpConnection]; - - yield put( - appConfigSliceActions.setLastTcpConnection(persistedValue ?? null), - ); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -function* persistLastTcpConnectionMetaWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - yield setValueInPersistedStore( - defaultStore, - PersistedStateKeys.LastTcpConnection, - action.payload ?? undefined, - ); - - // Fetch value from store to ensure it was set correctly - const persistedValue = (yield getValueFromPersistedStore( - defaultStore, - PersistedStateKeys.LastTcpConnection, - )) as IPersistedState[PersistedStateKeys.LastTcpConnection]; - - if (JSON.stringify(persistedValue) !== JSON.stringify(action.payload)) { - throw new Error("Failed to persist last TCP connection"); - } - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -/** - * Check if dark mode was manually selected or if system color mode is dark - * Add or remove the "dark" class from the document based on the selected mode - * https://tailwindcss.com/docs/dark-mode#supporting-system-preference-and-manual-selection - * @throws {Error} If the browser doesn't support the `matchMedia` API - * @param colorMode Color mode to apply a CSS class for - */ -function setColorModeClassWorker(colorMode: ColorMode) { - if ( - colorMode === "dark" || // Manual dark mode - (colorMode === "system" && - window.matchMedia("(prefers-color-scheme: dark)").matches) // System dark mode - ) { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); - } -} - -function* persistGeneralConfigWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - yield setValueInPersistedStore( - defaultStore, - PersistedStateKeys.GeneralConfig, - action.payload, - ); - - // Fetch value from store to ensure it was set correctly - const persistedValue = (yield getValueFromPersistedStore( - defaultStore, - PersistedStateKeys.GeneralConfig, - )) as IPersistedState[PersistedStateKeys.GeneralConfig]; - - if (JSON.stringify(persistedValue) !== JSON.stringify(action.payload)) { - throw new Error("Failed to persist general application config"); - } - - yield call(setColorModeClassWorker, action.payload.colorMode); - - // Update redux cache value - yield put(appConfigSliceActions.updateGeneralConfig(action.payload)); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -function* persistMapConfigWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - yield setValueInPersistedStore( - defaultStore, - PersistedStateKeys.MapConfig, - action.payload, - ); - - // Fetch value from store to ensure it was set correctly - const persistedValue = (yield getValueFromPersistedStore( - defaultStore, - PersistedStateKeys.MapConfig, - )) as IPersistedState[PersistedStateKeys.MapConfig]; - - if (JSON.stringify(persistedValue) !== JSON.stringify(action.payload)) { - throw new Error("Failed to persist map config"); - } - - // Update redux cache value - yield put(appConfigSliceActions.updateMapConfig(action.payload)); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -function* initializeAppConfigWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - const persistedGeneralConfig = (yield getValueFromPersistedStore( - defaultStore, - PersistedStateKeys.GeneralConfig, - )) as IPersistedState[PersistedStateKeys.GeneralConfig]; - - if (persistedGeneralConfig) { - yield put( - appConfigSliceActions.updateGeneralConfig(persistedGeneralConfig), - ); - } - - const persistedMapConfig = (yield getValueFromPersistedStore( - defaultStore, - PersistedStateKeys.MapConfig, - )) as IPersistedState[PersistedStateKeys.MapConfig]; - - if (persistedMapConfig) { - yield put(appConfigSliceActions.updateMapConfig(persistedMapConfig)); - } - - // Initialize color theme when data loaded - yield call( - setColorModeClassWorker, - persistedGeneralConfig?.colorMode ?? "system", - ); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -export function* appConfigSaga() { - yield all([ - takeEvery( - requestFetchLastTcpConnectionMeta.type, - fetchLastTcpConnectionMetaWorker, - ), - takeEvery( - requestPersistLastTcpConnectionMeta.type, - persistLastTcpConnectionMetaWorker, - ), - takeEvery(requestPersistGeneralConfig.type, persistGeneralConfigWorker), - takeEvery(requestPersistMapConfig.type, persistMapConfigWorker), - takeEvery(requestInitializeApplication.type, initializeAppConfigWorker), - ]); -} diff --git a/src/features/config/actions.ts b/src/features/config/actions.ts deleted file mode 100644 index 2a14dec0..00000000 --- a/src/features/config/actions.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { IConfigState } from "@features/config/slice"; -import { createAction } from "@reduxjs/toolkit"; - -export const requestCommitConfig = createAction<(keyof IConfigState)[]>( - "@device/request-commit-config", -); diff --git a/src/features/config/api.ts b/src/features/config/api.ts new file mode 100644 index 00000000..d60f39f0 --- /dev/null +++ b/src/features/config/api.ts @@ -0,0 +1,135 @@ +import { useDispatch, useSelector } from "react-redux"; +import merge from "lodash.merge"; +import cloneDeep from "lodash.clonedeep"; +import mergeWith from "lodash.mergewith"; + +import * as backendRadioApi from "@api/radio"; + +import { IConfigState, configSliceActions } from "./slice"; +import { + selectCurrentAllChannelConfig, + selectCurrentModuleConfig, + selectCurrentRadioConfig, + selectEditedAllChannelConfig, + selectEditedModuleConfig, + selectEditedRadioConfig, +} from "./selectors"; + +import { + app_device_MeshChannel, + app_ipc_DeviceBulkConfig, +} from "@bindings/index"; +import { selectPrimaryDeviceKey } from "@features/device/selectors"; + +import { getMeshChannelFromCurrentConfig } from "@utils/form"; +import { trackRequestOperation } from "@utils/api"; + +export enum ConfigApiActions { + CommitConfig = "config/commitConfig", +} + +export const useConfigApi = () => { + const dispatch = useDispatch(); + + const commitConfig = async (config: (keyof IConfigState)[]) => { + const TYPE = ConfigApiActions.CommitConfig; + + await trackRequestOperation(TYPE, dispatch, async () => { + const fieldFlags = config; + + const includeRadioConfig = fieldFlags.includes("radio"); + const includeModuleConfig = fieldFlags.includes("module"); + const includeChannelConfig = fieldFlags.includes("channel"); + + const primaryDeviceKey = useSelector(selectPrimaryDeviceKey()); + + if (!primaryDeviceKey) { + throw new Error("No active connection"); + } + + // Get current and edited config + + const currentRadioConfig = useSelector(selectCurrentRadioConfig()); + const editedRadioConfig = useSelector(selectEditedRadioConfig()); + const currentModuleConfig = useSelector(selectCurrentModuleConfig()); + const editedModuleConfig = useSelector(selectEditedModuleConfig()); + const currentChannelConfig = useSelector(selectCurrentAllChannelConfig()); + const editedChannelConfig = useSelector(selectEditedAllChannelConfig()); + + if (!(currentRadioConfig && currentModuleConfig)) { + throw new Error("Current radio or module config not defined"); + } + + const configPayload: app_ipc_DeviceBulkConfig = { + radio: null, + module: null, + channels: null, + }; + + // Update config payload based on flags + + if (includeRadioConfig) { + configPayload.radio = merge( + cloneDeep(currentRadioConfig), // Redux object + editedRadioConfig, + ); + } + + if (includeModuleConfig) { + configPayload.module = merge( + cloneDeep(currentModuleConfig), // Redux object + editedModuleConfig, + ); + } + + if (includeChannelConfig) { + if (!currentChannelConfig) { + throw new Error("Current channel config not defined"); + } + + const mergedConfig: Record = {}; + + for (const [idx, config] of Object.entries(editedChannelConfig)) { + if (!config) continue; + const channelNum = parseInt(idx); + const meshChannel = getMeshChannelFromCurrentConfig(config); + + mergedConfig[channelNum] = mergeWith( + cloneDeep(currentChannelConfig[channelNum]), + meshChannel, + // * Need to override array values instead of merging + (objVal, srcVal) => { + if (Array.isArray(objVal)) return srcVal; + }, + ); + } + + configPayload.channels = Object.values(mergedConfig).map( + (mc) => mc.config, + ); + } + + // Dispatch update to backend + + backendRadioApi.updateDeviceConfigBulk(primaryDeviceKey, configPayload); + + // Clear temporary config fields + + if (includeRadioConfig) { + dispatch(configSliceActions.clearRadioConfig()); + } + + if (includeModuleConfig) { + dispatch(configSliceActions.clearModuleConfig()); + } + + if (includeChannelConfig) { + dispatch(configSliceActions.clearChannelConfig()); + } + }); + }; + + return { + commitConfig, + }; +}; diff --git a/src/features/config/sagas.ts b/src/features/config/sagas.ts deleted file mode 100644 index c532536c..00000000 --- a/src/features/config/sagas.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { all, put, select, takeEvery } from "redux-saga/effects"; - -import cloneDeep from "lodash.clonedeep"; -import merge from "lodash.merge"; -import mergeWith from "lodash.mergewith"; - -import * as radioApi from "@api/backend/radio"; - -import type { - app_device_MeshChannel, - app_ipc_DeviceBulkConfig, -} from "@bindings/index"; - -import { requestCommitConfig } from "@features/config/actions"; -import { - selectCurrentAllChannelConfig, - selectCurrentModuleConfig, - selectCurrentRadioConfig, - selectEditedAllChannelConfig, - selectEditedModuleConfig, - selectEditedRadioConfig, -} from "@features/config/selectors"; -import { configSliceActions } from "@features/config/slice"; - -import { selectPrimaryDeviceKey } from "@features/device/selectors"; -import { requestSliceActions } from "@features/requests/slice"; - -import type { CommandError } from "@utils/errors"; -import { getMeshChannelFromCurrentConfig } from "@utils/form"; - -function* commitConfigWorker(action: ReturnType) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - const fieldFlags = action.payload; - - const includeRadioConfig = fieldFlags.includes("radio"); - const includeModuleConfig = fieldFlags.includes("module"); - const includeChannelConfig = fieldFlags.includes("channel"); - - const primaryDeviceKey = (yield select(selectPrimaryDeviceKey())) as - | string - | null; - - if (!primaryDeviceKey) { - throw new Error("No active connection"); - } - - // Get current and edited config - - const currentRadioConfig = (yield select( - selectCurrentRadioConfig(), - )) as ReturnType>; - - const editedRadioConfig = (yield select( - selectEditedRadioConfig(), - )) as ReturnType>; - - const currentModuleConfig = (yield select( - selectCurrentModuleConfig(), - )) as ReturnType>; - - const editedModuleConfig = (yield select( - selectEditedModuleConfig(), - )) as ReturnType>; - - const currentChannelConfig = (yield select( - selectCurrentAllChannelConfig(), - )) as ReturnType>; - - const editedChannelConfig = (yield select( - selectEditedAllChannelConfig(), - )) as ReturnType>; - - if (!(currentRadioConfig && currentModuleConfig)) { - throw new Error("Current radio or module config not defined"); - } - - const configPayload: app_ipc_DeviceBulkConfig = { - radio: null, - module: null, - channels: null, - }; - - // Update config payload based on flags - - if (includeRadioConfig) { - configPayload.radio = merge( - cloneDeep(currentRadioConfig), // Redux object - editedRadioConfig, - ); - } - - if (includeModuleConfig) { - configPayload.module = merge( - cloneDeep(currentModuleConfig), // Redux object - editedModuleConfig, - ); - } - - if (includeChannelConfig) { - if (!currentChannelConfig) { - throw new Error("Current channel config not defined"); - } - - const mergedConfig: Record = {}; - - for (const [idx, config] of Object.entries(editedChannelConfig)) { - if (!config) continue; - const channelNum = parseInt(idx); - const meshChannel = getMeshChannelFromCurrentConfig(config); - - mergedConfig[channelNum] = mergeWith( - cloneDeep(currentChannelConfig[channelNum]), - meshChannel, - // * Need to override array values instead of merging - (objVal, srcVal) => { - if (Array.isArray(objVal)) return srcVal; - }, - ); - } - - configPayload.channels = Object.values(mergedConfig).map( - (mc) => mc.config, - ); - } - - // Dispatch update to backend - - radioApi.updateDeviceConfigBulk(primaryDeviceKey, configPayload); - - // Clear temporary config fields - - if (includeRadioConfig) { - yield put(configSliceActions.clearRadioConfig()); - } - - if (includeModuleConfig) { - yield put(configSliceActions.clearModuleConfig()); - } - - if (includeChannelConfig) { - yield put(configSliceActions.clearChannelConfig()); - } - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -export function* configSaga() { - yield all([takeEvery(requestCommitConfig.type, commitConfigWorker)]); -} diff --git a/src/features/device/actions.ts b/src/features/device/actions.ts deleted file mode 100644 index 5294f1ce..00000000 --- a/src/features/device/actions.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createAction } from "@reduxjs/toolkit"; - -import type { - app_device_NormalizedWaypoint, - meshtastic_protobufs_User, -} from "@bindings/index"; - -import type { ConnectionType, DeviceKey } from "@utils/connections"; - -export const requestAvailablePorts = createAction( - "@device/request-available-ports", -); - -export const requestInitializeApplication = createAction( - "@device/request-initialize-application", -); - -export const requestConnectToDevice = createAction<{ - params: - | { - type: ConnectionType.SERIAL; - portName: string; - dtr: boolean; - rts: boolean; - } - | { type: ConnectionType.TCP; socketAddress: string }; - setPrimary: boolean; -}>("@device/request-connect"); - -export const requestDisconnectFromDevice = createAction( - "@device/request-disconnect", -); - -export const requestDisconnectFromAllDevices = createAction( - "@device/request-disconnect-all", -); - -export const requestSendMessage = createAction<{ - deviceKey: string; - text: string; - channel: number; -}>("@device/request-send-message"); - -export const requestUpdateUser = createAction<{ - deviceKey: string; - user: meshtastic_protobufs_User; -}>("@device/update-device-user"); - -export const requestSendWaypoint = createAction<{ - deviceKey: string; - waypoint: app_device_NormalizedWaypoint; - channel: number; -}>("@device/send-waypoint"); - -export const requestDeleteWaypoint = createAction<{ - deviceKey: string; - waypointId: number; -}>("@device/delete-waypoint"); - -export const requestAutoConnectPort = createAction( - "@device/request-autoconnect-port", -); diff --git a/src/features/device/api.ts b/src/features/device/api.ts new file mode 100644 index 00000000..9871f8af --- /dev/null +++ b/src/features/device/api.ts @@ -0,0 +1,243 @@ +import { useDispatch } from "react-redux"; + +import * as backendRadioApi from "@api/radio"; +import * as backendMeshApi from "@api/mesh"; +import * as backendConnectionApi from "@api/connection"; + +import { + app_device_NormalizedWaypoint, + meshtastic_protobufs_User, +} from "@bindings/index"; + +import { connectionSliceActions } from "@features/connection/slice"; +import { uiSliceActions } from "@features/ui/slice"; + +import { deviceSliceActions } from "./slice"; + +import { trackRequestOperation } from "@utils/api"; +import { ConnectionType, DeviceKey } from "@utils/connections"; +import { CommandError, throwError } from "@utils/errors"; + +export enum DeviceApiActions { + GetAutoConnectPort = "device/getAutoConnectPort", + SubscribeAll = "device/subscribeAll", + GetAvailableSerialPorts = "device/getAvailableSerialPorts", + InitializeApplication = "device/initializeApplication", + ConnectToDevice = "device/connectToDevice", + DisconnectFromDevice = "device/disconnectFromDevice", + DisconnectFromAllDevices = "device/disconnectFromAllDevices", + SendText = "device/sendText", + UpdateUserConfig = "device/updateUserConfig", + SendWaypoint = "device/sendWaypoint", + DeleteWaypoint = "device/deleteWaypoint", +} + +export const useDeviceApi = () => { + const dispatch = useDispatch(); + + const getAutoConnectPort = async () => { + const TYPE = DeviceApiActions.GetAutoConnectPort; + + await trackRequestOperation(TYPE, dispatch, async () => { + const portName = await backendConnectionApi.requestAutoConnectPort(); + + dispatch(deviceSliceActions.setAutoConnectPort(portName)); + + // Automatically connect to port + if (portName) { + await connectToDevice({ + params: { + type: ConnectionType.SERIAL, + portName, + dtr: true, + rts: false, + }, + setPrimary: true, + }); + } + }); + }; + + const getAvailableSerialPorts = async () => { + const TYPE = DeviceApiActions.GetAvailableSerialPorts; + + await trackRequestOperation(TYPE, dispatch, async () => { + const serialPorts = await backendConnectionApi.getAllSerialPorts(); + + dispatch(deviceSliceActions.setAvailableSerialPorts(serialPorts)); + }); + }; + + const connectToDevice = async (payload: { + params: + | { + type: ConnectionType.SERIAL; + portName: string; + dtr: boolean; + rts: boolean; + } + | { type: ConnectionType.TCP; socketAddress: string }; + setPrimary: boolean; + }) => { + const TYPE = DeviceApiActions.ConnectToDevice; + + const deviceKey: DeviceKey = + payload.params.type === ConnectionType.SERIAL + ? payload.params.portName + : payload.params.type === ConnectionType.TCP + ? payload.params.socketAddress + : throwError("Neither portName nor socketAddress were set"); + + await trackRequestOperation( + TYPE, + dispatch, + async () => { + dispatch( + connectionSliceActions.setConnectionState({ + deviceKey, + status: { + status: "PENDING", + }, + }), + ); + + if (payload.params.type === ConnectionType.SERIAL) { + await backendConnectionApi.connectToSerialPort( + payload.params.portName, + undefined, + payload.params.dtr, + payload.params.rts, + ); + } + + if (payload.params.type === ConnectionType.TCP) { + await backendConnectionApi.connectToTcpPort( + payload.params.socketAddress, + ); + } + + if (payload.setPrimary) { + if (payload.params.type === ConnectionType.SERIAL) { + dispatch( + deviceSliceActions.setPrimaryDeviceConnectionKey( + payload.params.portName, + ), + ); + } + + if (payload.params.type === ConnectionType.TCP) { + dispatch( + deviceSliceActions.setPrimaryDeviceConnectionKey( + payload.params.socketAddress, + ), + ); + } + } + }, + (e) => { + dispatch( + connectionSliceActions.setConnectionState({ + deviceKey, + status: { + status: "FAILED", + message: (e as CommandError).message, + }, + }), + ); + }, + ); + }; + + const disconnectFromDevice = async (payload: DeviceKey) => { + const TYPE = DeviceApiActions.DisconnectFromDevice; + + await trackRequestOperation(TYPE, dispatch, async () => { + await backendConnectionApi.dropDeviceConnection(payload); + + dispatch(deviceSliceActions.setPrimaryDeviceConnectionKey(null)); + dispatch(uiSliceActions.setActiveNode(null)); + dispatch(deviceSliceActions.setDevice(null)); + }); + }; + + const disconnectFromAllDevices = async () => { + const TYPE = DeviceApiActions.DisconnectFromAllDevices; + + await trackRequestOperation(TYPE, dispatch, async () => { + await backendConnectionApi.dropAllDeviceConnections(); + + dispatch(deviceSliceActions.setPrimaryDeviceConnectionKey(null)); + dispatch(uiSliceActions.setActiveNode(null)); + dispatch(deviceSliceActions.setDevice(null)); + }); + }; + + const sendText = async (payload: { + deviceKey: string; + text: string; + channel: number; + }) => { + const TYPE = DeviceApiActions.SendText; + + await trackRequestOperation(TYPE, dispatch, async () => { + await backendMeshApi.sendText( + payload.deviceKey, + payload.channel, + payload.text, + ); + }); + }; + + const updateUserConfig = async (payload: { + deviceKey: string; + user: meshtastic_protobufs_User; + }) => { + const TYPE = DeviceApiActions.UpdateUserConfig; + + await trackRequestOperation(TYPE, dispatch, async () => { + await backendRadioApi.updateDeviceUser(payload.deviceKey, payload.user); + }); + }; + + const sendWaypoint = async (payload: { + deviceKey: string; + waypoint: app_device_NormalizedWaypoint; + channel: number; + }) => { + const TYPE = DeviceApiActions.SendWaypoint; + + await trackRequestOperation(TYPE, dispatch, async () => { + await backendMeshApi.sendWaypoint( + payload.deviceKey, + payload.channel, + payload.waypoint, + ); + }); + }; + + const deleteWaypoint = async (payload: { + deviceKey: string; + waypointId: number; + }) => { + const TYPE = DeviceApiActions.DeleteWaypoint; + + await trackRequestOperation(TYPE, dispatch, async () => { + await backendMeshApi.deleteWaypoint( + payload.deviceKey, + payload.waypointId, + ); + }); + }; + + return { + getAutoConnectPort, + getAvailableSerialPorts, + connectToDevice, + disconnectFromDevice, + disconnectFromAllDevices, + sendText, + updateUserConfig, + sendWaypoint, + deleteWaypoint, + }; +}; diff --git a/src/features/device/connectionHandlerSagas.ts b/src/features/device/connectionHandlerSagas.ts deleted file mode 100644 index 93bd827c..00000000 --- a/src/features/device/connectionHandlerSagas.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { listen } from "@tauri-apps/api/event"; -import { EventChannel, eventChannel } from "redux-saga"; -import { call, put, take } from "redux-saga/effects"; - -import type { app_device_MeshDevice } from "@bindings/index"; - -import { connectionSliceActions } from "@features/connection/slice"; -import { requestDisconnectFromDevice } from "@features/device/actions"; -import { deviceSliceActions } from "@features/device/slice"; - -import type { DeviceKey } from "@utils/connections"; - -export type DeviceUpdateChannel = EventChannel; -export type DeviceDisconnectChannel = EventChannel; -export type ConfigStatusChannel = EventChannel; -export type RebootChannel = EventChannel; - -function* handleSagaError(error: unknown) { - yield put({ type: "GENERAL_ERROR", payload: error }); -} - -export const createDeviceUpdateChannel = (): DeviceUpdateChannel => { - return eventChannel((emitter) => { - listen("device_update", (event) => { - emitter(event.payload); - }) - // .then((unlisten) => { - // return unlisten; - // }) - .catch(console.error); - - // TODO UNLISTEN - return () => null; - }); -}; - -export function* handleDeviceUpdateChannel(channel: DeviceUpdateChannel) { - try { - while (true) { - const meshDevice: app_device_MeshDevice = yield take(channel); - yield put(deviceSliceActions.setDevice(meshDevice)); - } - } catch (error) { - yield call(handleSagaError, error); - } -} - -export const createDeviceDisconnectChannel = (): DeviceDisconnectChannel => { - return eventChannel((emitter) => { - listen("device_disconnect", (event) => { - emitter(event.payload); - }) - // .then((unlisten) => { - // return unlisten; - // }) - .catch(console.error); - - // TODO UNLISTEN - return () => null; - }); -}; - -export function* handleDeviceDisconnectChannel( - channel: DeviceDisconnectChannel, -) { - try { - while (true) { - const portName: string = yield take(channel); - yield put(requestDisconnectFromDevice(portName)); - window.location.reload(); - } - } catch (error) { - yield call(handleSagaError, error); - } -} - -export const createConfigStatusChannel = (): ConfigStatusChannel => { - return eventChannel((emitter) => { - listen("configuration_status", (event) => { - emitter(event.payload); - }) - // .then((unlisten) => { - // return unlisten; - // }) - .catch(console.error); - - // TODO UNLISTEN - return () => null; - }); -}; - -export function* handleConfigStatusChannel(channel: ConfigStatusChannel) { - try { - while (true) { - const { - successful, - deviceKey, - message, - }: { - successful: boolean; - deviceKey: DeviceKey; - message: string | null; - } = yield take(channel); - - if (!successful) { - yield put(requestDisconnectFromDevice(deviceKey)); - } - - yield put( - connectionSliceActions.setConnectionState({ - deviceKey: deviceKey, - status: successful - ? { status: "SUCCESSFUL" } - : { status: "FAILED", message: message ?? "" }, - }), - ); - } - } catch (error) { - yield call(handleSagaError, error); - } -} - -export const createRebootChannel = (): RebootChannel => { - return eventChannel((emitter) => { - listen("reboot", (event) => { - emitter(event.payload); - }) - // .then((unlisten) => { - // return unlisten; - // }) - .catch(console.error); - - // TODO UNLISTEN - return () => null; - }); -}; - -export function* handleRebootChannel(channel: RebootChannel) { - try { - while (true) { - const timestamp_sec: number = yield take(channel); - const reboot_time = new Date(timestamp_sec * 1000); - console.warn("Rebooting at", reboot_time); - window.location.reload(); - } - } catch (error) { - yield call(handleSagaError, error); - } -} diff --git a/src/features/device/sagas.ts b/src/features/device/sagas.ts deleted file mode 100644 index df7468b4..00000000 --- a/src/features/device/sagas.ts +++ /dev/null @@ -1,353 +0,0 @@ -import type { Task } from "redux-saga"; -import { all, call, cancel, fork, put, takeEvery } from "redux-saga/effects"; - -import * as radioApi from "@api/backend/radio"; -import * as meshApi from "@api/backend/mesh"; -import * as connectionApi from "@api/backend/connection"; - -import { connectionSliceActions } from "@features/connection/slice"; -import { - requestAutoConnectPort, - requestAvailablePorts, - requestConnectToDevice, - requestDeleteWaypoint, - requestDisconnectFromAllDevices, - requestDisconnectFromDevice, - requestInitializeApplication, - requestSendMessage, - requestSendWaypoint, - requestUpdateUser, -} from "@features/device/actions"; -import { - ConfigStatusChannel, - DeviceDisconnectChannel, - DeviceUpdateChannel, - RebootChannel, - createConfigStatusChannel, - createDeviceDisconnectChannel, - createDeviceUpdateChannel, - createRebootChannel, - handleConfigStatusChannel, - handleDeviceDisconnectChannel, - handleDeviceUpdateChannel, - handleRebootChannel, -} from "@features/device/connectionHandlerSagas"; -import { deviceSliceActions } from "@features/device/slice"; -import { requestSliceActions } from "@features/requests/slice"; -import { uiSliceActions } from "@features/ui/slice"; - -import { ConnectionType, DeviceKey } from "@utils/connections"; -import type { CommandError } from "@utils/errors"; -import { error } from "@utils/errors"; - -// Currently this only suports serial ports -async function* getAutoConnectPortWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - const portName = await connectionApi.requestAutoConnectPort(); - - yield put(deviceSliceActions.setAutoConnectPort(portName)); - - // Automatically connect to port - if (portName) { - yield put( - requestConnectToDevice({ - params: { - type: ConnectionType.SERIAL, - portName, - dtr: true, - rts: false, - }, - setPrimary: true, - }), - ); - } - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -function* subscribeAll() { - const deviceUpdateChannel: DeviceUpdateChannel = yield call( - createDeviceUpdateChannel, - ); - - const deviceDisconnectChannel: DeviceDisconnectChannel = yield call( - createDeviceDisconnectChannel, - ); - - const configStatusChannel: ConfigStatusChannel = yield call( - createConfigStatusChannel, - ); - - const rebootChannel: RebootChannel = yield call(createRebootChannel); - - yield all([ - call(handleDeviceUpdateChannel, deviceUpdateChannel), - call(handleDeviceDisconnectChannel, deviceDisconnectChannel), - call(handleConfigStatusChannel, configStatusChannel), - call(handleRebootChannel, rebootChannel), - ]); -} - -async function* getAvailableSerialPortsWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - const serialPorts = await connectionApi.getAllSerialPorts(); - - yield put(deviceSliceActions.setAvailableSerialPorts(serialPorts)); - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -function* initializeApplicationWorker( - action: ReturnType, -) { - try { - yield call(subscribeAll); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -async function* connectToDeviceWorker( - action: ReturnType, -) { - let subscribeTask: Task | null = null; - const deviceKey: DeviceKey = - action.payload.params.type === ConnectionType.SERIAL - ? action.payload.params.portName - : action.payload.params.type === ConnectionType.TCP - ? action.payload.params.socketAddress - : error("Neither portName nor socketAddress were set"); - - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - yield put( - connectionSliceActions.setConnectionState({ - deviceKey, - status: { - status: "PENDING", - }, - }), - ); - - // Need to subscribe to events before connecting - // * Can't block as these are infinite loops - subscribeTask = yield fork(subscribeAll) as unknown as Task; - - if (action.payload.params.type === ConnectionType.SERIAL) { - await connectionApi.connectToSerialPort( - action.payload.params.portName, - undefined, - action.payload.params.dtr, - action.payload.params.rts, - ); - } - - if (action.payload.params.type === ConnectionType.TCP) { - await connectionApi.connectToTcpPort(action.payload.params.socketAddress); - } - - if (action.payload.setPrimary) { - if (action.payload.params.type === ConnectionType.SERIAL) { - yield put( - deviceSliceActions.setPrimaryDeviceConnectionKey( - action.payload.params.portName, - ), - ); - } - - if (action.payload.params.type === ConnectionType.TCP) { - yield put( - deviceSliceActions.setPrimaryDeviceConnectionKey( - action.payload.params.socketAddress, - ), - ); - } - } - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - - // ! Will eventually need to kill these tasks - // ! to avoid memory leaks with many devices connected - // if (subscribeTask) { - // yield subscribeTask?.toPromise(); - // } - } catch (e) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (e as CommandError).message, - }), - ); - - yield put( - connectionSliceActions.setConnectionState({ - deviceKey, - status: { - status: "FAILED", - message: (e as CommandError).message, - }, - }), - ); - - if (subscribeTask) { - yield cancel(subscribeTask); - } - } -} - -async function* disconnectFromDeviceWorker( - action: ReturnType, -) { - try { - await connectionApi.dropDeviceConnection(action.payload); - - yield put(deviceSliceActions.setPrimaryDeviceConnectionKey(null)); - yield put(uiSliceActions.setActiveNode(null)); - yield put(deviceSliceActions.setDevice(null)); - } catch (error) { - yield put({ type: "GENERAL_ERROR", payload: error }); - } -} - -async function* disconnectFromAllDevicesWorker() { - try { - await connectionApi.dropAllDeviceConnections(); - - yield put(deviceSliceActions.setPrimaryDeviceConnectionKey(null)); - yield put(uiSliceActions.setActiveNode(null)); - yield put(deviceSliceActions.setDevice(null)); - } catch (error) { - yield put({ type: "GENERAL_ERROR", payload: error }); - } -} - -async function* sendTextWorker(action: ReturnType) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - await meshApi.sendText( - action.payload.deviceKey, - action.payload.channel, - action.payload.text, - ); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -function* updateUserConfigWorker(action: ReturnType) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - radioApi.updateDeviceUser(action.payload.deviceKey, action.payload.user); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -async function* sendWaypointWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - await meshApi.sendWaypoint( - action.payload.deviceKey, - action.payload.channel, - action.payload.waypoint, - ); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - console.error(error); - // TODO error.message doesn't catch invalid Tauri type errors - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -async function* deleteWaypointWorker( - action: ReturnType, -) { - try { - yield put(requestSliceActions.setRequestPending({ name: action.type })); - - await meshApi.deleteWaypoint( - action.payload.deviceKey, - action.payload.waypointId, - ); - - yield put(requestSliceActions.setRequestSuccessful({ name: action.type })); - } catch (error) { - yield put( - requestSliceActions.setRequestFailed({ - name: action.type, - message: (error as CommandError).message, - }), - ); - } -} - -export function* devicesSaga() { - yield all([ - takeEvery(requestAutoConnectPort.type, getAutoConnectPortWorker), - takeEvery(requestAvailablePorts.type, getAvailableSerialPortsWorker), - takeEvery(requestInitializeApplication.type, initializeApplicationWorker), - takeEvery(requestConnectToDevice.type, connectToDeviceWorker), - takeEvery(requestDisconnectFromDevice.type, disconnectFromDeviceWorker), - takeEvery( - requestDisconnectFromAllDevices.type, - disconnectFromAllDevicesWorker, - ), - takeEvery(requestSendMessage.type, sendTextWorker), - takeEvery(requestUpdateUser.type, updateUserConfigWorker), - takeEvery(requestSendWaypoint.type, sendWaypointWorker), - takeEvery(requestDeleteWaypoint.type, deleteWaypointWorker), - ]); -} diff --git a/src/main.tsx b/src/main.tsx index 8f5c6ad8..e925d0a6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import { Provider } from "react-redux"; import { BrowserRouter } from "react-router-dom"; import { App } from "@app/App"; +import { AppInitWrapper } from "@components/AppInitWrapper"; import { store } from "@store/index"; // Load translations @@ -53,9 +54,11 @@ root.render( Loading locales...}> - - - + + + + + diff --git a/src/store/index.ts b/src/store/index.ts index 7bf7c8d5..df62014b 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,8 +1,5 @@ import { configureStore } from "@reduxjs/toolkit"; import { logger } from "redux-logger"; -import createSagaMiddleware from "redux-saga"; - -import { requestInitializeApplication } from "@features/device/actions"; import { appConfigReducer } from "@features/appConfig/slice"; import { configReducer } from "@features/config/slice"; @@ -12,10 +9,7 @@ import { mapReducer } from "@features/map/slice"; import { requestReducer } from "@features/requests/slice"; import { uiReducer } from "@features/ui/slice"; -import { rootSaga } from "@store/saga"; - -const sagaMiddleware = createSagaMiddleware(); -const middleware = [sagaMiddleware, logger]; +const middleware = [logger]; export const store = configureStore({ reducer: { @@ -32,9 +26,5 @@ export const store = configureStore({ devTools: true, }); -sagaMiddleware.run(rootSaga); - -store.dispatch(requestInitializeApplication()); - export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/src/store/persistence.ts b/src/store/persistence.ts new file mode 100644 index 00000000..0205da3a --- /dev/null +++ b/src/store/persistence.ts @@ -0,0 +1,4 @@ +import { Store } from "tauri-plugin-store-api"; +import { DEFAULT_STORE_FILE_NAME } from "@utils/persistence"; + +export const defaultStore = new Store(DEFAULT_STORE_FILE_NAME); diff --git a/src/store/saga.ts b/src/store/saga.ts deleted file mode 100644 index ceb0e36b..00000000 --- a/src/store/saga.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { all, fork } from "redux-saga/effects"; - -import { appConfigSaga } from "@features/appConfig/sagas"; -import { configSaga } from "@features/config/sagas"; -import { devicesSaga } from "@features/device/sagas"; - -export function* rootSaga() { - yield all([fork(appConfigSaga), fork(configSaga), fork(devicesSaga)]); -} diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 00000000..1c94031e --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,57 @@ +import { requestSliceActions } from "@features/requests/slice"; +import { AppDispatch } from "@store/index"; +import { CommandError, isCommandError } from "./errors"; + +export async function trackRequestOperation( + type: string, + dispatch: AppDispatch, + handler: () => Promise | void, + errorCallback?: (error: CommandError) => Promise | void, +) { + try { + dispatch( + requestSliceActions.setRequestPending({ + name: type, + }), + ); + + await handler(); + + dispatch(requestSliceActions.setRequestSuccessful({ name: type })); + } catch (error) { + handleUnknownError(dispatch, error); + + await errorCallback?.(error as CommandError); + } +} + +export function handleUnknownError(dispatch: AppDispatch, error: unknown) { + if (isCommandError(error)) { + dispatch( + requestSliceActions.setRequestFailed({ + name: "commandError", + message: error.message, + }), + ); + + return; + } + + if (typeof error === "string") { + dispatch( + requestSliceActions.setRequestFailed({ + name: "error", + message: error, + }), + ); + + return; + } + + dispatch( + requestSliceActions.setRequestFailed({ + name: "unknownError", + message: `An unexpected error occurred: ${JSON.stringify(error)}`, + }), + ); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 528347ea..7fb40c20 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -2,7 +2,11 @@ export interface CommandError { message: string; } -export function error(message: string): never { +export function isCommandError(error: unknown): error is CommandError { + return (error as CommandError).message !== undefined; +} + +export function throwError(message: string): never { return (() => { throw new Error(message); })(); diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 001ed525..b57081c7 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -1,5 +1,5 @@ -import { error } from "@utils/errors"; import { useEffect, useState } from "react"; +import { throwError } from "@utils/errors"; /** * This hook is intended for use in components that need to rerender @@ -35,7 +35,7 @@ export interface IUseDarkMode { export const useIsDarkMode = (): IUseDarkMode => { // Browser needs to support matchMedia but can't make useState conditional if (!window.matchMedia) { - error("Browser doesn't support window.matchMedia method"); + throwError("Browser doesn't support window.matchMedia method"); } const runQuery = () => window.matchMedia(COLOR_SCHEME_QUERY); diff --git a/src/utils/persistence.ts b/src/utils/persistence.ts index f384752a..d872139c 100644 --- a/src/utils/persistence.ts +++ b/src/utils/persistence.ts @@ -19,23 +19,40 @@ export interface IPersistedState { [PersistedStateKeys.MapConfig]?: IMapConfigState; } -export function* setValueInPersistedStore( +export async function setAndValidateValueInPersistedStore< + TKey extends keyof IPersistedState, + TValue extends IPersistedState[TKey], +>( store: Store, key: TKey, - value: IPersistedState[TKey], + value: TValue, save = true, + errorMessage: string | null = null, ) { - yield store.set(key, value); + await setValueInPersistedStore(store, key, value, save); + + const fetchedValue = await getValueFromPersistedStore(store, key); + + if (JSON.stringify(fetchedValue) !== JSON.stringify(value)) { + throw new Error( + errorMessage ?? "Failed to persist value in persistent store", + ); + } +} + +export async function setValueInPersistedStore< + TKey extends keyof IPersistedState, +>(store: Store, key: TKey, value: IPersistedState[TKey], save = true) { + await store.set(key, value); if (save) { - yield store.save(); + await store.save(); } } -export function* getValueFromPersistedStore( - store: Store, - key: TKey, -) { - const val = (yield store.get(key)) as IPersistedState[TKey]; +export async function getValueFromPersistedStore< + TKey extends keyof IPersistedState, +>(store: Store, key: TKey) { + const val = await store.get(key); return val; } diff --git a/src/utils/ui.ts b/src/utils/ui.ts new file mode 100644 index 00000000..066845c4 --- /dev/null +++ b/src/utils/ui.ts @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { ColorMode } from "@features/appConfig/slice"; + +/** + * Check if dark mode was manually selected or if system color mode is dark + * Add or remove the "dark" class from the document based on the selected mode + * https://tailwindcss.com/docs/dark-mode#supporting-system-preference-and-manual-selection + * @throws {Error} If the browser doesn't support the `matchMedia` API + * @param colorMode Color mode to apply a CSS class for + */ +export function setColorModeClass(colorMode: ColorMode) { + if ( + colorMode === "dark" || // Manual dark mode + (colorMode === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches) // System dark mode + ) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } +} + +export const useAsyncUnlistenUseEffect = ( + effect: () => Promise<() => void>, + deps: unknown[], +) => { + // biome-ignore lint/nursery/noEmptyBlockStatements: Needed for no-op unlisten + let unlistenFn = () => {}; + + useEffect(() => { + effect().then((unlisten) => { + unlistenFn = unlisten; + }); + + return () => { + unlistenFn(); + }; + }, deps); +}; From d5a618e6dc641dffcb47421109117f53de33e347 Mon Sep 17 00:00:00 2001 From: Adam McQuilkin <46639306+ajmcquilkin@users.noreply.github.com> Date: Mon, 19 Feb 2024 16:23:47 -0800 Subject: [PATCH 3/3] Removed unused listeners --- src/main.tsx | 39 +++++---------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index e925d0a6..beae7573 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,3 @@ -import { listen } from "@tauri-apps/api/event"; import React, { Suspense } from "react"; import { createRoot } from "react-dom/client"; import { MapProvider } from "react-map-gl"; @@ -15,41 +14,13 @@ import "./i18n"; import "maplibre-gl/dist/maplibre-gl.css"; import "./index.css"; -listen("config_complete", (event) => { - console.log("config_complete", event.payload); -}) - .then((unlisten) => { - return unlisten; - }) - .catch(console.error); - -listen("log_record", (event) => { - console.log("log_record", event.payload); -}) - .then((unlisten) => { - return unlisten; - }) - .catch(console.error); - -listen("my_node_info", (event) => { - console.log("my_node_info", event.payload); -}) - .then((unlisten) => { - return unlisten; - }) - .catch(console.error); +const container = document.getElementById("root"); -listen("reboot", (event) => { - console.log("reboot", event.payload); -}) - .then((unlisten) => { - return unlisten; - }) - .catch(console.error); +if (!container) { + throw new Error("Root element not found"); +} -const container = document.getElementById("root"); -const root = createRoot(container as HTMLElement); -root.render( +createRoot(container).render( Loading locales...}>