From 2744948862d03682b8849a4d62db8f4f2cc05071 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Sun, 23 Aug 2026 21:48:32 -0400 Subject: [PATCH] fix(web): batch of small bug fixes and UX polish This bundles a set of narrow, independent fixes across the web client. * Position config (Fixes #1051, #1308) - Altitude field now converts between meters (firmware) and feet (display) based on the display-units setting, so a user typing a "feet" value no longer gets it silently stored as meters. Browser geolocation altitude is likewise converted before it lands in the form. Altitude step is corrected from the copy-pasted lat/lon precision (0.0000001) to whole-unit steps. - Latitude/longitude field length caps are widened so signed seven-decimal values (e.g. -90.1234567 / -180.1234567) are typeable. * Numeric form input (Fixes #1308, #1404) - `FormInput` no longer runs every keystroke through `Number.parseFloat(...).toString()`, which turned partial input like "-", "0.", or an empty string into "NaN" and made it impossible to type a negative or a decimal. The controller now holds the raw string; the zod `.coerce.number()` on the schema converts on submit. - Fixed a display bug where `String(undefined)` rendered the literal text "undefined" inside empty number inputs. - `txPower` schema floor lowered from 0 to -18 dBm to allow external PA attenuation values that the firmware already accepts. * Messages (Fixes #1270, #1271, #1277) - `MessageItem` no longer crashes when `message.from` is null; the hex id derivation guards against it. - `useSuspendingMyNode` stops resolving Suspense with an empty object cast to `NodeInfo`. It now resolves a plain void promise per tick and tracks a shared deadline so the timeout is honoured across renders. - The message list key uses `message.messageId` directly; the `${from}-${date}` fallback collided on same-second sends. * Connections (Fixes #1272) - `removeConnection` used to swallow every `removeDevice` error in a bare `catch {}`. Failures are now logged with name/message so partial-cleanup bugs are debuggable. * Nodes page (Fixes #911) - Show a `filtered/total` count next to the search + filter controls so users can see how many nodes their filter matched. * Unread indicator (Fixes #96) - Prefix `document.title` with a bullet when the SDK reports any unread messages, so a background tab surfaces new activity without cluttering the title with per-conversation names. * Map page (Fixes #1041) - Persist the last viewed longitude / latitude / zoom to localStorage on `moveend` and restore it as the initial view state on next mount. When a saved view exists, the one-shot fit-to-nodes bootstrap is skipped so the user's chosen framing isn't clobbered. --- apps/web/src/App.tsx | 16 +++++ apps/web/src/components/Form/FormInput.tsx | 14 ++-- .../PageComponents/Messages/ChannelChat.tsx | 5 +- .../PageComponents/Messages/MessageItem.tsx | 65 +++++++++---------- .../PageComponents/Settings/Position.tsx | 46 +++++++------ .../src/pages/Connections/useConnections.ts | 10 ++- apps/web/src/pages/Map/index.tsx | 64 +++++++++++++++++- apps/web/src/pages/Nodes/index.tsx | 3 + apps/web/src/validation/config/lora.ts | 3 +- 9 files changed, 158 insertions(+), 68 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 67f0b89c7..5cd8eb747 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,14 +9,30 @@ import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import Footer from "@components/UI/Footer.tsx"; import { useTheme } from "@core/hooks/useTheme.ts"; import { SidebarProvider, useAppStore, useDeviceStore } from "@core/stores"; +import { useTotalUnread } from "@meshtastic/sdk-react"; import { Connections } from "@pages/Connections/index.tsx"; import { Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { useEffect, useRef } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { MapProvider } from "react-map-gl/maplibre"; +function useUnreadTitleIndicator() { + const baseTitleRef = useRef(""); + const unread = useTotalUnread(); + + useEffect(() => { + if (!baseTitleRef.current) { + baseTitleRef.current = document.title.replace(/^●\s*/, ""); + } + document.title = + unread > 0 ? `● ${baseTitleRef.current}` : baseTitleRef.current; + }, [unread]); +} + export function App() { useTheme(); + useUnreadTitleIndicator(); const { getDevice } = useDeviceStore(); const { selectedDeviceId } = useAppStore(); diff --git a/apps/web/src/components/Form/FormInput.tsx b/apps/web/src/components/Form/FormInput.tsx index 487f96569..208aa438d 100644 --- a/apps/web/src/components/Form/FormInput.tsx +++ b/apps/web/src/components/Form/FormInput.tsx @@ -59,11 +59,9 @@ export function GenericInput({ field.inputChange(e); } - controllerField.onChange( - field.type === "number" - ? Number.parseFloat(newValue).toString() - : newValue, - ); + // Forward the raw string so partial input like "-" or "0." isn't + // mangled mid-type; the schema coerces on submit. + controllerField.onChange(newValue); }; const currentLength = controllerField.value @@ -76,9 +74,9 @@ export function GenericInput({ type={field.type} step={field.properties?.step} value={ - field.type === "number" - ? String(controllerField.value) - : controllerField.value + controllerField.value === undefined || controllerField.value === null + ? "" + : String(controllerField.value) } id={field.name} onChange={handleInputChange} diff --git a/apps/web/src/components/PageComponents/Messages/ChannelChat.tsx b/apps/web/src/components/PageComponents/Messages/ChannelChat.tsx index f9a08744e..c689dab03 100644 --- a/apps/web/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/apps/web/src/components/PageComponents/Messages/ChannelChat.tsx @@ -148,10 +148,7 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { {/* Render messages first, then delimiter — with flex-col-reverse this shows the delimiter above that day's messages */} {items.map((message) => ( - } - > + }> ))} diff --git a/apps/web/src/components/PageComponents/Messages/MessageItem.tsx b/apps/web/src/components/PageComponents/Messages/MessageItem.tsx index 7309b23fb..9a80be039 100644 --- a/apps/web/src/components/PageComponents/Messages/MessageItem.tsx +++ b/apps/web/src/components/PageComponents/Messages/MessageItem.tsx @@ -19,48 +19,46 @@ import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import { type ReactNode, useMemo } from "react"; import { useTranslation } from "react-i18next"; -// Cache for pending promises -const myNodePromises = new Map>(); +const myNodePromises = new Map>(); +const myNodeDeadlines = new Map(); +const MY_NODE_TIMEOUT_MS = 10_000; -// Hook that suspends when myNode is not available. Reads from the SDK -// (NodesClient signal hydrated by sqlocal + live packets) instead of the -// legacy Zustand nodeDB. The polling fallback remains because there is a -// gap between mount and first onMyNodeInfo packet on a fresh connect. +// Suspends until myNode hydrates. Times out after 10s. function useSuspendingMyNode() { const selectedDeviceId = useAppStore((s) => s.selectedDeviceId); const myNode = useMyNodeAsProto(); - if (!myNode) { + if (myNode) { const deviceKey = `device-${selectedDeviceId}`; + myNodePromises.delete(deviceKey); + myNodeDeadlines.delete(deviceKey); + return myNode; + } - if (!myNodePromises.has(deviceKey)) { - const promise = new Promise((resolve, reject) => { - // setTimeout with a 100ms tick lets React re-render this component - // (and therefore re-run the hook) until myNode resolves through the - // SDK signal. Suspense re-throws the promise on each retry until - // the value is available. - const start = Date.now(); - const tick = () => { - if (Date.now() - start > 10000) { - myNodePromises.delete(deviceKey); - reject(new Error("myNode not available after 10s")); - return; - } - // Resolve a no-op promise to retrigger the Suspense boundary; - // the next render will call useMyNodeAsProto again. - resolve({} as Protobuf.Mesh.NodeInfo); - myNodePromises.delete(deviceKey); - }; - setTimeout(tick, 100); - }); + const deviceKey = `device-${selectedDeviceId}`; + const deadline = + myNodeDeadlines.get(deviceKey) ?? Date.now() + MY_NODE_TIMEOUT_MS; + myNodeDeadlines.set(deviceKey, deadline); - myNodePromises.set(deviceKey, promise); - } + if (Date.now() > deadline) { + myNodePromises.delete(deviceKey); + myNodeDeadlines.delete(deviceKey); + throw new Error("myNode not available after 10s"); + } - throw myNodePromises.get(deviceKey); + if (!myNodePromises.has(deviceKey)) { + myNodePromises.set( + deviceKey, + new Promise((resolve) => { + setTimeout(() => { + myNodePromises.delete(deviceKey); + resolve(); + }, 100); + }), + ); } - return myNode; + throw myNodePromises.get(deviceKey); } // import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented @@ -148,7 +146,8 @@ export const MessageItem = ({ message }: MessageItemProps) => { message.from != null ? (messageUserNode ?? null) : null; const { displayName, isFavorite, nodeNum } = useMemo(() => { - const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0"); + const fromNum = message.from ?? 0; + const userIdHex = fromNum.toString(16).toUpperCase().padStart(2, "0"); const last4 = userIdHex.slice(-4); const fallbackName = t("fallbackName", { last4 }); const longName = messageUser?.user?.longName; @@ -160,7 +159,7 @@ export const MessageItem = ({ message }: MessageItemProps) => { displayName: derivedDisplayName, shortName: derivedShortName, isFavorite: isFavorite, - nodeNum: message.from, + nodeNum: fromNum, }; }, [messageUser, message.from, t, myNodeNum]); diff --git a/apps/web/src/components/PageComponents/Settings/Position.tsx b/apps/web/src/components/PageComponents/Settings/Position.tsx index c61674695..26541bf12 100644 --- a/apps/web/src/components/PageComponents/Settings/Position.tsx +++ b/apps/web/src/components/PageComponents/Settings/Position.tsx @@ -27,12 +27,17 @@ interface PositionConfigProps { onFormInit: DynamicFormFormInit; } +// Firmware stores altitude in meters; form displays user's chosen unit. +const METERS_PER_FOOT = 0.3048; +const metersToFeet = (m: number) => m / METERS_PER_FOOT; +const feetToMeters = (ft: number) => ft * METERS_PER_FOOT; + /** * Renders inside the Device GPS card. Pulls the browser's current location * via navigator.geolocation and writes lat/lng/altitude into the form. * No-op without a geolocation API (e.g. insecure context). */ -function UseBrowserLocationButton() { +function UseBrowserLocationButton({ isImperial }: { isImperial: boolean }) { const { setValue } = useFormContext(); const { toast } = useToast(); const { t } = useTranslation("config"); @@ -57,7 +62,10 @@ function UseBrowserLocationButton() { pos.coords.altitude !== null && !Number.isNaN(pos.coords.altitude) ) { - setValue("altitude", Math.round(pos.coords.altitude), { + const altitude = isImperial + ? metersToFeet(pos.coords.altitude) + : pos.coords.altitude; + setValue("altitude", Math.round(altitude), { shouldDirty: true, }); } @@ -113,8 +121,11 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { const currentPosition = myNode?.position; const displayUnits = getEffectiveConfig("display")?.units; + const isImperial = + displayUnits === Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL; const formValues = useMemo(() => { + const altitudeMeters = currentPosition?.altitude ?? 0; return { ...config.position, ...effectivePosition, @@ -124,9 +135,11 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { longitude: currentPosition?.longitudeI ? currentPosition.longitudeI / 1e7 : undefined, - altitude: currentPosition?.altitude ?? 0, + altitude: isImperial + ? Math.round(metersToFeet(altitudeMeters)) + : altitudeMeters, } as PositionValidation; - }, [config.position, effectivePosition, currentPosition]); + }, [config.position, effectivePosition, currentPosition, isImperial]); const onSubmit = (data: PositionValidation) => { const { @@ -149,13 +162,16 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { data.latitude !== undefined && data.longitude !== undefined ) { + const altitudeMeters = isImperial + ? Math.round(feetToMeters(data.altitude ?? 0)) + : Math.round(data.altitude ?? 0); const message = create(Protobuf.Admin.AdminMessageSchema, { payloadVariant: { case: "setFixedPosition", value: create(Protobuf.Mesh.PositionSchema, { latitudeI: Math.round(data.latitude * 1e7), longitudeI: Math.round(data.longitude * 1e7), - altitude: data.altitude || 0, + altitude: altitudeMeters, time: Math.floor(Date.now() / 1000), }), }, @@ -220,7 +236,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { { label: t("position.deviceGps.label"), description: t("position.deviceGps.description"), - footer: , + footer: , fields: [ { type: "toggle", @@ -236,7 +252,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { properties: { step: 0.0000001, suffix: "Degrees", - fieldLength: { max: 10 }, + fieldLength: { max: 12 }, }, disabledBy: [{ fieldName: "fixedPosition" }], }, @@ -248,7 +264,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { properties: { step: 0.0000001, suffix: "Degrees", - fieldLength: { max: 10 }, + fieldLength: { max: 13 }, }, disabledBy: [{ fieldName: "fixedPosition" }], }, @@ -257,19 +273,11 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { name: "altitude", label: t("position.fixedPosition.altitude.label"), description: t("position.fixedPosition.altitude.description", { - unit: - displayUnits === - Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL - ? "Feet" - : "Meters", + unit: isImperial ? "Feet" : "Meters", }), properties: { - step: 0.0000001, - suffix: - displayUnits === - Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL - ? "Feet" - : "Meters", + step: 1, + suffix: isImperial ? "Feet" : "Meters", }, disabledBy: [{ fieldName: "fixedPosition" }], }, diff --git a/apps/web/src/pages/Connections/useConnections.ts b/apps/web/src/pages/Connections/useConnections.ts index d8cf7d0c5..43ee261b7 100644 --- a/apps/web/src/pages/Connections/useConnections.ts +++ b/apps/web/src/pages/Connections/useConnections.ts @@ -88,7 +88,15 @@ export function useConnections() { if (conn?.meshDeviceId) { try { useDeviceStore.getState().removeDevice(conn.meshDeviceId); - } catch {} + } catch (e) { + const err = e as Error; + log.error("removeConnection: removeDevice threw", { + id, + meshDeviceId: conn.meshDeviceId, + name: err?.name, + message: err?.message, + }); + } } meshRegistry.unregister(id); removeSavedConnectionFromStore(id); diff --git a/apps/web/src/pages/Map/index.tsx b/apps/web/src/pages/Map/index.tsx index f9d4a4bfc..5b52f3eec 100644 --- a/apps/web/src/pages/Map/index.tsx +++ b/apps/web/src/pages/Map/index.tsx @@ -31,12 +31,13 @@ import { } from "@core/hooks/useNodesAsProto.ts"; import { cn } from "@core/utils/cn.ts"; import { hasPos, toLngLat } from "@core/utils/geo.ts"; -import type { Protobuf } from "@meshtastic/sdk"; +import { createLogger, type Protobuf } from "@meshtastic/sdk"; import { numberToHexUnpadded } from "@noble/curves/utils.js"; import { FunnelIcon, LocateFixedIcon } from "lucide-react"; import { useCallback, useDeferredValue, + useEffect, useId, useMemo, useRef, @@ -45,6 +46,35 @@ import { import { useTranslation } from "react-i18next"; import { type MapLayerMouseEvent, useMap } from "react-map-gl/maplibre"; +const log = createLogger("MapPage"); +const MAP_VIEW_STORAGE_KEY = "meshtastic:mapView"; + +type SavedMapView = { longitude: number; latitude: number; zoom: number }; + +function loadSavedMapView(): SavedMapView | undefined { + if (typeof localStorage === "undefined") return undefined; + try { + const raw = localStorage.getItem(MAP_VIEW_STORAGE_KEY); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.longitude === "number" && + typeof parsed.latitude === "number" && + typeof parsed.zoom === "number" + ) { + return parsed as SavedMapView; + } + log.warn("saved map view has unexpected shape; ignoring", parsed); + } catch (e) { + const err = e as Error; + log.warn("failed to read saved map view", { + name: err?.name, + message: err?.message, + }); + } + return undefined; +} + const MapPage = () => { const { t } = useTranslation("map"); const allNodes = useNodesAsProto(); @@ -64,7 +94,36 @@ const MapPage = () => { const { default: mapRef } = useMap(); const { focusLngLat, fitToNodes } = useMapFitting(mapRef); - const hasFitBoundsOnce = useRef(false); + // Read once so the ref is stable across renders. + const savedView = useRef(loadSavedMapView()); + // Skip auto-fit-to-nodes if a saved view already positioned the map. + const hasFitBoundsOnce = useRef(!!savedView.current); + + useEffect(() => { + const map = mapRef?.getMap(); + if (!map) return; + const handler = () => { + const center = map.getCenter(); + const view: SavedMapView = { + longitude: center.lng, + latitude: center.lat, + zoom: map.getZoom(), + }; + try { + localStorage.setItem(MAP_VIEW_STORAGE_KEY, JSON.stringify(view)); + } catch (e) { + const err = e as Error; + log.warn("failed to persist map view", { + name: err?.name, + message: err?.message, + }); + } + }; + map.on("moveend", handler); + return () => { + map.off("moveend", handler); + }; + }, [mapRef]); const [snrHover, setSnrHover] = useState(); const [expandedCluster, setExpandedCluster] = useState(); const [popupState, setPopupState] = useState(); @@ -240,6 +299,7 @@ const MapPage = () => { return ( }> { } /> +
+ {filteredNodes.length}/{allSdkNodes.length} +