feat(connections): Add connections page (replaces new device dialog) (#919)
* feat(conn): add connection screen and logic * fixes from code review * force https * code review fixes * add http for self testing * enable deviceStore persistance * added translations * disabled feature flag * i18n updates * chore: add new folders to biome config (#910) * chore(i18n): New Crowdin Translations by GitHub Action (#908) Co-authored-by: Crowdin Bot <support+bot@crowdin.com> * fix: use correct deprecated GPS coordinate format enum (#917) The Config_DisplayConfig_GpsCoordinateFormat export doesn't exist in the protobufs package. The correct export is Config_DisplayConfig_DeprecatedGpsCoordinateFormat, which matches what's used in the validation schema. Added TODO comment explaining that this field is deprecated since protobufs 2.7.4 and should be migrated to DeviceUIConfig.gps_format when DeviceUI settings are implemented. * style: fix line wrapping for GPS coordinate format enum (#918) - Split long enum reference across multiple lines to improve code readability - Maintains consistent code formatting standards without changing functionality * fix(core): ensure core package works in browser (#923) * fix(core): ensure core package works in browser * style(core): revert new line removal * fix: add @serialport/bindings-cpp to onlyBuiltDependencies (#914) * feat(ui): Add UI library (#900) * feat: scaffold UI library * Update packages/ui/src/components/theme-provider.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add lock file * lint/formatting fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * formatting/linting fixes * fixed some paring logic * fixed connection issue with serial --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Crowdin Bot <support+bot@crowdin.com> Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com> Co-authored-by: Azarattum <43073346+Azarattum@users.noreply.github.com> Co-authored-by: Ben Allfree <ben@benallfree.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Crowdin Bot
Kamil Dzieniszewski
Azarattum
Ben Allfree
parent
a1a646983e
commit
1af1295b8f
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* A minimal serializable wrapper so we can store metadata alongside items.
|
||||
*/
|
||||
type PersistedPayload<J = unknown> = {
|
||||
v: 1; // schema version
|
||||
capacity: number; // last used capacity (for info/migrations)
|
||||
items: J[]; // serialized items (MRU -> LRU)
|
||||
};
|
||||
|
||||
export type UseLRUListOptions<T, J = unknown> = {
|
||||
/** localStorage key */
|
||||
key: string;
|
||||
/** max number of items to keep (>=1) */
|
||||
capacity: number;
|
||||
/** optional initial items used when storage is empty/invalid */
|
||||
initial?: T[];
|
||||
/** equality to de-duplicate items; default: Object.is */
|
||||
eq?: (a: T, b: T) => boolean;
|
||||
/** convert T -> JSON-safe type; default: (x) => x as unknown as J */
|
||||
toJSON?: (t: T) => J;
|
||||
/** convert JSON-safe type -> T; default: (x) => x as unknown as T */
|
||||
fromJSON?: (j: J) => T;
|
||||
/** storage impl (for tests); default: window.localStorage */
|
||||
storage?: Storage;
|
||||
/** listen to storage events and live-sync across tabs/windows; default: true */
|
||||
syncTabs?: boolean;
|
||||
};
|
||||
|
||||
export type UseLRUListReturn<T> = {
|
||||
/** Items ordered MRU -> LRU */
|
||||
items: T[];
|
||||
/** Add or "touch" an item (move to MRU); inserts if missing */
|
||||
add: (item: T) => void;
|
||||
/** Remove a matching item (no-op if missing) */
|
||||
remove: (item: T) => void;
|
||||
/** Clear all items */
|
||||
clear: () => void;
|
||||
/** Whether a matching item exists */
|
||||
has: (item: T) => boolean;
|
||||
/** Replace the entire list (applies LRU trimming) */
|
||||
replaceAll: (next: T[]) => void;
|
||||
/** Current capacity (for information) */
|
||||
capacity: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* useLRUList<T> – maintains a most-recently-used list and persists it to localStorage.
|
||||
*
|
||||
* MRU is index 0. Adding an existing item "touches" it (moves to front).
|
||||
*/
|
||||
export function useLRUList<T, J = unknown>(
|
||||
opts: UseLRUListOptions<T, J>,
|
||||
): UseLRUListReturn<T> {
|
||||
const {
|
||||
key,
|
||||
capacity,
|
||||
initial = [],
|
||||
eq = Object.is,
|
||||
toJSON = (x: T) => x as unknown as J,
|
||||
fromJSON = (x: J) => x as unknown as T,
|
||||
storage = typeof window !== "undefined" ? window.localStorage : undefined,
|
||||
syncTabs = true,
|
||||
} = opts;
|
||||
|
||||
if (capacity < 1) {
|
||||
// Fail fast in dev; silently coerce in prod
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
throw new Error("useLRUList: capacity must be >= 1");
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveCapacity = Math.max(1, capacity);
|
||||
|
||||
// Guard against SSR or no-storage environments
|
||||
const canPersist = !!storage && typeof storage.getItem === "function";
|
||||
|
||||
const readPersisted = useCallback((): T[] | null => {
|
||||
if (!canPersist) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedPayload<J>;
|
||||
if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.items)) {
|
||||
return null;
|
||||
}
|
||||
const deserialized = parsed.items.map(fromJSON);
|
||||
return trimToCapacity(deserialized, effectiveCapacity);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [canPersist, storage, key, fromJSON, effectiveCapacity]);
|
||||
|
||||
const writePersisted = useCallback(
|
||||
(items: T[]) => {
|
||||
if (!canPersist) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: PersistedPayload<J> = {
|
||||
v: 1,
|
||||
capacity: effectiveCapacity,
|
||||
items: items.map(toJSON),
|
||||
};
|
||||
storage.setItem(key, JSON.stringify(payload));
|
||||
} catch {
|
||||
// Swallow quota/serialization errors; keep in-memory state working
|
||||
}
|
||||
},
|
||||
[canPersist, storage, key, toJSON, effectiveCapacity],
|
||||
);
|
||||
|
||||
// Initialize from storage (or fallback to `initial`)
|
||||
const [items, setItems] = useState<T[]>(
|
||||
() => readPersisted() ?? trimToCapacity([...initial], effectiveCapacity),
|
||||
);
|
||||
|
||||
// Keep a ref to avoid feedback loops when applying remote (storage event) updates
|
||||
const applyingExternal = useRef(false);
|
||||
|
||||
// Persist on changes
|
||||
useEffect(() => {
|
||||
if (applyingExternal.current) {
|
||||
applyingExternal.current = false;
|
||||
return;
|
||||
}
|
||||
writePersisted(items);
|
||||
}, [items, writePersisted]);
|
||||
|
||||
// Cross-tab synchronization via storage events
|
||||
useEffect(() => {
|
||||
if (!syncTabs || !canPersist || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.storageArea !== storage || e.key !== key) {
|
||||
return;
|
||||
}
|
||||
// Another tab changed it; re-read safely
|
||||
const next = readPersisted();
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
applyingExternal.current = true;
|
||||
setItems(next);
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [syncTabs, canPersist, storage, key, readPersisted]);
|
||||
|
||||
// Helpers
|
||||
const indexOf = useCallback(
|
||||
(arr: T[], needle: T) => arr.findIndex((x) => eq(x, needle)),
|
||||
[eq],
|
||||
);
|
||||
|
||||
const add = useCallback(
|
||||
(item: T) => {
|
||||
setItems((prev) => {
|
||||
const idx = indexOf(prev, item);
|
||||
if (idx === 0) {
|
||||
return prev; // already MRU
|
||||
}
|
||||
if (idx > 0) {
|
||||
const next = [...prev];
|
||||
next.splice(idx, 1);
|
||||
next.unshift(item);
|
||||
return next;
|
||||
}
|
||||
// Not present: insert at MRU and trim
|
||||
const next = [item, ...prev];
|
||||
return trimToCapacity(next, effectiveCapacity);
|
||||
});
|
||||
},
|
||||
[indexOf, effectiveCapacity],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(item: T) => {
|
||||
setItems((prev) => {
|
||||
const idx = indexOf(prev, item);
|
||||
if (idx === -1) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
next.splice(idx, 1);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[indexOf],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => setItems([]), []);
|
||||
|
||||
const has = useCallback(
|
||||
(item: T) => indexOf(items, item) !== -1,
|
||||
[items, indexOf],
|
||||
);
|
||||
|
||||
const replaceAll = useCallback(
|
||||
(next: T[]) => setItems(trimToCapacity([...next], effectiveCapacity)),
|
||||
[effectiveCapacity],
|
||||
);
|
||||
|
||||
// Stable API shape
|
||||
return useMemo(
|
||||
() => ({
|
||||
items,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
has,
|
||||
replaceAll,
|
||||
capacity: effectiveCapacity,
|
||||
}),
|
||||
[items, add, remove, clear, has, replaceAll, effectiveCapacity],
|
||||
);
|
||||
}
|
||||
|
||||
// --- utils ---
|
||||
|
||||
function trimToCapacity<T>(arr: T[], capacity: number): T[] {
|
||||
if (arr.length <= capacity) {
|
||||
return arr;
|
||||
}
|
||||
return arr.slice(0, capacity);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ if (isDev) {
|
||||
featureFlags.setOverrides({
|
||||
persistNodeDB: true,
|
||||
persistMessages: true,
|
||||
persistDevices: false,
|
||||
persistApp: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export type ValidModuleConfigType =
|
||||
export type ConfigChangeKey =
|
||||
| { type: "config"; variant: ValidConfigType }
|
||||
| { type: "moduleConfig"; variant: ValidModuleConfigType }
|
||||
| { type: "channel"; index: Types.ChannelNumber }
|
||||
| { type: "channels"; index: Types.ChannelNumber }
|
||||
| { type: "user" };
|
||||
|
||||
// Serialized key for Map storage
|
||||
@@ -57,7 +57,7 @@ export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
|
||||
return `config:${key.variant}`;
|
||||
case "moduleConfig":
|
||||
return `moduleConfig:${key.variant}`;
|
||||
case "channel":
|
||||
case "channels":
|
||||
return `channel:${key.index}`;
|
||||
case "user":
|
||||
return "user";
|
||||
@@ -78,9 +78,9 @@ export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
|
||||
type: "moduleConfig",
|
||||
variant: variant as ValidModuleConfigType,
|
||||
};
|
||||
case "channel":
|
||||
case "channels":
|
||||
return {
|
||||
type: "channel",
|
||||
type: "channels",
|
||||
index: Number(variant) as Types.ChannelNumber,
|
||||
};
|
||||
case "user":
|
||||
@@ -126,7 +126,7 @@ export function hasChannelChange(
|
||||
registry: ChangeRegistry,
|
||||
index: Types.ChannelNumber,
|
||||
): boolean {
|
||||
return registry.changes.has(serializeKey({ type: "channel", index }));
|
||||
return registry.changes.has(serializeKey({ type: "channels", index }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +171,7 @@ export function getChannelChangeCount(registry: ChangeRegistry): number {
|
||||
let count = 0;
|
||||
for (const keyStr of registry.changes.keys()) {
|
||||
const key = deserializeKey(keyStr);
|
||||
if (key.type === "channel") {
|
||||
if (key.type === "channels") {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ export function getAllModuleConfigChanges(
|
||||
export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
|
||||
const changes: ChangeEntry[] = [];
|
||||
for (const entry of registry.changes.values()) {
|
||||
if (entry.key.type === "channel") {
|
||||
if (entry.key.type === "channels") {
|
||||
changes.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { create, toBinary } from "@bufbuild/protobuf";
|
||||
import { featureFlags } from "@core/services/featureFlags";
|
||||
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
||||
@@ -26,6 +25,8 @@ import {
|
||||
serializeKey,
|
||||
} from "./changeRegistry.ts";
|
||||
import type {
|
||||
Connection,
|
||||
ConnectionId,
|
||||
Dialogs,
|
||||
DialogVariant,
|
||||
ValidConfigType,
|
||||
@@ -142,6 +143,16 @@ export interface deviceState {
|
||||
removeDevice: (id: number) => void;
|
||||
getDevices: () => Device[];
|
||||
getDevice: (id: number) => Device | undefined;
|
||||
|
||||
// Saved connections management
|
||||
savedConnections: Connection[];
|
||||
addSavedConnection: (connection: Connection) => void;
|
||||
updateSavedConnection: (
|
||||
id: ConnectionId,
|
||||
updates: Partial<Connection>,
|
||||
) => void;
|
||||
removeSavedConnection: (id: ConnectionId) => void;
|
||||
getSavedConnections: () => Connection[];
|
||||
}
|
||||
|
||||
interface PrivateDeviceState extends deviceState {
|
||||
@@ -150,6 +161,7 @@ interface PrivateDeviceState extends deviceState {
|
||||
|
||||
type DevicePersisted = {
|
||||
devices: Map<number, DeviceData>;
|
||||
savedConnections: Connection[];
|
||||
};
|
||||
|
||||
function deviceFactory(
|
||||
@@ -894,6 +906,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
||||
get,
|
||||
) => ({
|
||||
devices: new Map(),
|
||||
savedConnections: [],
|
||||
|
||||
addDevice: (id) => {
|
||||
const existing = get().devices.get(id);
|
||||
@@ -924,6 +937,41 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
||||
},
|
||||
getDevices: () => Array.from(get().devices.values()),
|
||||
getDevice: (id) => get().devices.get(id),
|
||||
|
||||
addSavedConnection: (connection) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
draft.savedConnections.push(connection);
|
||||
}),
|
||||
);
|
||||
},
|
||||
updateSavedConnection: (id, updates) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const conn = draft.savedConnections.find(
|
||||
(c: Connection) => c.id === id,
|
||||
);
|
||||
if (conn) {
|
||||
for (const key in updates) {
|
||||
if (Object.hasOwn(updates, key)) {
|
||||
(conn as Record<string, unknown>)[key] =
|
||||
updates[key as keyof typeof updates];
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeSavedConnection: (id) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
draft.savedConnections = draft.savedConnections.filter(
|
||||
(c: Connection) => c.id !== id,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
getSavedConnections: () => get().savedConnections,
|
||||
});
|
||||
|
||||
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
||||
@@ -943,6 +991,7 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
||||
},
|
||||
]),
|
||||
),
|
||||
savedConnections: s.savedConnections,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) {
|
||||
@@ -980,14 +1029,6 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Add persist middleware on the store if the feature flag is enabled
|
||||
const persistDevices = featureFlags.get("persistDevices");
|
||||
console.debug(
|
||||
`DeviceStore: Persisting devices is ${persistDevices ? "enabled" : "disabled"}`,
|
||||
export const useDeviceStore = createStore(
|
||||
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
|
||||
);
|
||||
|
||||
export const useDeviceStore = persistDevices
|
||||
? createStore(
|
||||
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
|
||||
)
|
||||
: createStore(subscribeWithSelector(deviceStoreInitializer));
|
||||
|
||||
@@ -28,6 +28,46 @@ type DialogVariant = keyof Dialogs;
|
||||
|
||||
type Page = "messages" | "map" | "settings" | "channels" | "nodes";
|
||||
|
||||
export type ConnectionId = number;
|
||||
export type ConnectionType = "http" | "bluetooth" | "serial";
|
||||
export type ConnectionStatus =
|
||||
| "connected"
|
||||
| "connecting"
|
||||
| "disconnected"
|
||||
| "disconnecting"
|
||||
| "configuring"
|
||||
| "configured"
|
||||
| "online"
|
||||
| "error";
|
||||
|
||||
export type Connection = {
|
||||
id: ConnectionId;
|
||||
type: ConnectionType;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
lastConnectedAt?: number;
|
||||
isDefault?: boolean;
|
||||
status: ConnectionStatus;
|
||||
error?: string;
|
||||
meshDeviceId?: number;
|
||||
} & NewConnection;
|
||||
|
||||
export type NewConnection =
|
||||
| { type: "http"; name: string; url: string }
|
||||
| {
|
||||
type: "bluetooth";
|
||||
name: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
gattServiceUUID?: string;
|
||||
}
|
||||
| {
|
||||
type: "serial";
|
||||
name: string;
|
||||
usbVendorId?: number;
|
||||
usbProductId?: number;
|
||||
};
|
||||
|
||||
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
|
||||
metadata: {
|
||||
channel: number; // Channel on which the waypoint was received
|
||||
|
||||
Reference in New Issue
Block a user