-
-
{label}
-
+ {leftBar}
+
+ )}
+
+ {/* Center column (Header + Main) */}
+
+ {/* Header */}
+
+ {/* Header Content */}
+
+
+ {label}
+
+
{actions?.map((action) => (
))}
-
-
-
- {children}
-
+
+
+
+ {children}
+
+ {/* */}
+
+ {/* Right Sidebar */}
+ {rightBar && (
+
+ )}
);
-};
+};
\ No newline at end of file
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 68e0d525..26b378bd 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -1,148 +1,254 @@
+import React from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx";
+import { Avatar } from "@components/UI/Avatar.tsx";
import {
BatteryMediumIcon,
+ CircleChevronLeft,
CpuIcon,
- EditIcon,
LayersIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
+ PenLine,
+ SearchIcon,
SettingsIcon,
- SidebarCloseIcon,
- SidebarOpenIcon,
UsersIcon,
ZapIcon,
} from "lucide-react";
-import { useState } from "react";
+import { cn } from "@core/utils/cn.ts";
+import { useSidebar } from "@core/stores/sidebarStore.tsx";
+import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
+import { useAppStore } from "@core/stores/appStore.ts";
export interface SidebarProps {
children?: React.ReactNode;
}
+interface NavLink {
+ name: string;
+ icon: LucideIcon;
+ page: Page;
+}
+
+const CollapseToggleButton = () => {
+ const { isCollapsed, toggleSidebar } = useSidebar();
+ const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar";
+
+ return (
+
+ );
+}
+
+const getBatteryStatus = (level: number | undefined): string => {
+ if (level === undefined) return "UNK";
+ if (level > 100) return "Charging";
+ return `${level}%`;
+};
+
+
export const Sidebar = ({ children }: SidebarProps) => {
- const { hardware, nodes, metadata } = useDevice();
- const myNode = nodes.get(hardware.myNodeNum);
+ const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice();
+ const { setCommandPaletteOpen } = useAppStore();
+ const myNode = getNode(hardware.myNodeNum);
+ const { isCollapsed } = useSidebar();
const myMetadata = metadata.get(0);
- const { activePage, setActivePage, setDialogOpen } = useDevice();
- const [showSidebar, setShowSidebar] = useState
(true);
-
- interface NavLink {
- name: string;
- icon: LucideIcon;
- page: Page;
- }
const pages: NavLink[] = [
+ { name: "Messages", icon: MessageSquareIcon, page: "messages" },
+ { name: "Map", icon: MapIcon, page: "map" },
+ { name: "Config", icon: SettingsIcon, page: "config" },
+ { name: "Channels", icon: LayersIcon, page: "channels" },
{
- name: "Messages",
- icon: MessageSquareIcon,
- page: "messages",
- },
- {
- name: "Map",
- icon: MapIcon,
- page: "map",
- },
- {
- name: "Config",
- icon: SettingsIcon,
- page: "config",
- },
- {
- name: "Channels",
- icon: LayersIcon,
- page: "channels",
- },
- {
- name: `Nodes (${Math.max(nodes.size - 1, 0)})`,
+ name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`,
icon: UsersIcon,
page: "nodes",
},
];
- return showSidebar
- ? (
-
+ return (
+
+
+
+
+

+
+ Meshtastic
+
+
+
+
+ {pages.map((link) => (
+ {
+ if (myNode !== undefined) {
+ setActivePage(link.page);
+ }
+ }}
+ active={link.page === activePage}
+ disabled={myNode === undefined}
+ />
+ ))}
+
+
+
+ {children}
+
+
+
{myNode === undefined ? (
-
+
- Loading device info...
+
+ Loading...
+
) : (
<>
-
-
-
- {myNode.user?.shortName ?? "UNK"}
-
-
{myNode.user?.longName ?? "UNK"}
+
+
+
+
+ {myNode.user?.longName ?? "UNK"}
+
+
+
+
+
+
+ {getBatteryStatus(myNode.deviceMetrics?.batteryLevel)}
+
+
+
+ {myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
+
+
+
+ v{myMetadata?.firmwareVersion ?? "UNK"}
+
+
+
-
-
-
-
-
- {myNode.deviceMetrics?.batteryLevel
- ? myNode.deviceMetrics.batteryLevel > 100
- ? "Charging"
- : `${myNode.deviceMetrics.batteryLevel}%`
- : "UNK"}
-
-
-
-
-
- {myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
-
-
-
-
- v{myMetadata?.firmwareVersion ?? "UNK"}
-
-
+
>
)}
-
-
- {pages.map((link) => (
- {
- if (myNode !== undefined) {
- setActivePage(link.page);
- }
- }}
- active={link.page === activePage}
- disabled={myNode === undefined}
- />
- ))}
-
- {children}
- )
- : (
-
- setShowSidebar(true)}>
-
-
-
- );
-};
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx
index ee075793..00c958fd 100644
--- a/src/components/ThemeSwitcher.tsx
+++ b/src/components/ThemeSwitcher.tsx
@@ -12,9 +12,9 @@ export default function ThemeSwitcher({
const { theme, preference, setPreference } = useTheme();
const themeIcons = {
- light:
,
- dark:
,
- system:
,
+ light:
,
+ dark:
,
+ system:
,
};
const toggleTheme = () => {
@@ -30,7 +30,7 @@ export default function ThemeSwitcher({
{firstCharOfPreference.toLocaleUpperCase() +
(restOfPreference ?? []).join("")}
diff --git a/src/components/UI/Avatar.tsx b/src/components/UI/Avatar.tsx
index 5ea1d3ed..b834a0a8 100644
--- a/src/components/UI/Avatar.tsx
+++ b/src/components/UI/Avatar.tsx
@@ -49,7 +49,7 @@ export const Avatar: React.FC = ({
className,
}) => {
const sizes = {
- sm: "size-11 text-xs",
+ sm: "size-10 text-xs font-light",
lg: "size-16 text-lg",
};
diff --git a/src/components/UI/Sidebar/SidebarSection.tsx b/src/components/UI/Sidebar/SidebarSection.tsx
index 7eefc0a0..cf284f0a 100644
--- a/src/components/UI/Sidebar/SidebarSection.tsx
+++ b/src/components/UI/Sidebar/SidebarSection.tsx
@@ -1,19 +1,42 @@
-import { Heading } from "../Typography/Heading.tsx";
+import React from "react";
+import { cn } from "@core/utils/cn.ts";
+import { Heading } from "@components/UI/Typography/Heading.tsx";
+import { useSidebar } from "@core/stores/sidebarStore.tsx";
-export interface SidebarSectionProps {
+interface SidebarSectionProps {
label: string;
- subheader?: string;
children: React.ReactNode;
+ className?: string;
}
export const SidebarSection = ({
- label: title,
+ label,
children,
-}: SidebarSectionProps) => (
-
-
- {title}
-
-
{children}
-
-);
+ className,
+}: SidebarSectionProps) => {
+ const { isCollapsed } = useSidebar();
+ return (
+
+
+
+ {label}
+
+
+
+ {children}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/UI/Sidebar/sidebarButton.tsx b/src/components/UI/Sidebar/sidebarButton.tsx
index b55952db..349b9b47 100644
--- a/src/components/UI/Sidebar/sidebarButton.tsx
+++ b/src/components/UI/Sidebar/sidebarButton.tsx
@@ -1,5 +1,8 @@
+import React from "react";
import { Button } from "@components/UI/Button.tsx";
import type { LucideIcon } from "lucide-react";
+import { cn } from "@core/utils/cn.ts";
+import { useSidebar } from "@core/stores/sidebarStore.tsx";
export interface SidebarButtonProps {
label: string;
@@ -19,17 +22,54 @@ export const SidebarButton = ({
children,
onClick,
disabled = false,
-}: SidebarButtonProps) => (
-
- {Icon && }
- {children && children}
- {label}
- {count && count > 0 && !active && {count}
}
-
-);
+}: SidebarButtonProps) => {
+ const { isCollapsed } = useSidebar();
+
+ return (
+
+ {Icon && (
+
+ )}
+
+ {children}
+
+
+ {label}
+
+
+ {!isCollapsed && !active && count && count > 0 && (
+
+ {count}
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/UI/Typography/Heading.tsx b/src/components/UI/Typography/Heading.tsx
index 1efeaa30..9a322e80 100644
--- a/src/components/UI/Typography/Heading.tsx
+++ b/src/components/UI/Typography/Heading.tsx
@@ -4,7 +4,7 @@ const headingStyles = {
h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
h2:
"scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700",
- h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
+ h3: "scroll-m-20 text-lg font-semibold tracking-tight",
h4: "scroll-m-20 text-xl font-semibold tracking-tight",
h5: "scroll-m-20 text-lg font-medium tracking-tight",
};
diff --git a/src/core/dto/NodeNumToNodeInfoDTO.ts b/src/core/dto/NodeNumToNodeInfoDTO.ts
new file mode 100644
index 00000000..60222502
--- /dev/null
+++ b/src/core/dto/NodeNumToNodeInfoDTO.ts
@@ -0,0 +1,40 @@
+import { create } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/core";
+
+class NodeInfoFactory {
+ private static createDefaultUser(num: number): Protobuf.Mesh.User {
+ const userIdHex = num.toString(16).toUpperCase().padStart(2, '0');
+ const userId = `!${userIdHex}`;
+ const last4 = userIdHex.slice(-4);
+ const longName = `Meshtastic ${last4}`;
+ const shortName = last4;
+ const hwModel = Protobuf.Mesh.HardwareModel.UNSET;
+
+ return create(Protobuf.Mesh.UserSchema, {
+ id: userId,
+ longName: longName,
+ shortName: shortName,
+ hwModel: hwModel,
+ isLicensed: false,
+ });
+ }
+
+ public static ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo {
+ if (!node) {
+ return node;
+ }
+
+ if (!node.user) {
+ if (node.num === undefined || node.num === null) {
+ console.error(`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`, node);
+ return node;
+ }
+
+ node.user = NodeInfoFactory.createDefaultUser(node.num);
+ }
+
+ return node;
+ }
+}
+
+export default NodeInfoFactory;
\ No newline at end of file
diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts
index 518f8565..152ee671 100644
--- a/src/core/stores/deviceStore.ts
+++ b/src/core/stores/deviceStore.ts
@@ -6,31 +6,13 @@ import { create as createStore } from "zustand";
export type Page = "messages" | "map" | "config" | "channels" | "nodes";
-export interface MessageWithState extends Types.PacketMetadata {
- state: MessageState;
-}
-
-export type MessageState = "ack" | "waiting" | 'failed';
-
export interface ProcessPacketParams {
from: number;
snr: number;
time: number;
}
-export type DialogVariant =
- | "import"
- | "QR"
- | "shutdown"
- | "reboot"
- | "rebootOTA"
- | "deviceName"
- | "nodeRemoval"
- | "pkiBackup"
- | "nodeDetails"
- | "unsafeRoles"
- | "refreshKeys"
- | "deleteMessages";
+export type DialogVariant = keyof Device["dialog"];
type NodeError = {
node: number;
@@ -46,7 +28,6 @@ export interface Device {
workingConfig: Protobuf.Config.Config[];
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
hardware: Protobuf.Mesh.MyNodeInfo;
- nodes: Map;
metadata: Map;
traceroutes: Map<
number,
@@ -57,10 +38,10 @@ export interface Device {
activePage: Page;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
- // currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean;
messageDraft: string;
unreadCounts: Map;
+ nodesMap: Map;
dialog: {
import: boolean;
QR: boolean;
@@ -76,14 +57,12 @@ export interface Device {
deleteMessages: boolean;
};
-
setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
- // setMetrics: (metrics: Types.PacketMetadata) => void;
setActivePage: (page: Page) => void;
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
@@ -105,9 +84,15 @@ export interface Device {
setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined;
- hasNodeError: (nodeNum: number) => boolean
+ hasNodeError: (nodeNum: number) => boolean;
incrementUnread: (nodeNum: number) => void;
resetUnread: (nodeNum: number) => void;
+ getNodes: (
+ filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
+ ) => Protobuf.Mesh.NodeInfo[];
+ getNodesLength: () => number;
+ getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
+
}
export interface DeviceState {
@@ -125,6 +110,7 @@ export const useDeviceStore = createStore((set, get) => ({
remoteDevices: new Map(),
addDevice: (id: number) => {
+
set(
produce((draft) => {
draft.devices.set(id, {
@@ -136,7 +122,6 @@ export const useDeviceStore = createStore((set, get) => ({
workingConfig: [],
workingModuleConfig: [],
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
- nodes: new Map(),
metadata: new Map(),
traceroutes: new Map(),
connection: undefined,
@@ -161,8 +146,9 @@ export const useDeviceStore = createStore((set, get) => ({
messageDraft: "",
nodeErrors: new Map(),
unreadCounts: new Map(),
+ nodesMap: new Map(),
-
+ // --- Standard Setter Methods ---
setStatus: (status: Types.DeviceStatusEnum) => {
set(
produce((draft) => {
@@ -177,40 +163,16 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
const device = draft.devices.get(id);
-
if (device) {
switch (config.payloadVariant.case) {
- case "device": {
- device.config.device = config.payloadVariant.value;
- break;
- }
- case "position": {
- device.config.position = config.payloadVariant.value;
- break;
- }
- case "power": {
- device.config.power = config.payloadVariant.value;
- break;
- }
- case "network": {
- device.config.network = config.payloadVariant.value;
- break;
- }
- case "display": {
- device.config.display = config.payloadVariant.value;
- break;
- }
- case "lora": {
- device.config.lora = config.payloadVariant.value;
- break;
- }
- case "bluetooth": {
- device.config.bluetooth = config.payloadVariant.value;
- break;
- }
- case "security": {
- device.config.security = config.payloadVariant.value;
- }
+ case "device": { device.config.device = config.payloadVariant.value; break; }
+ case "position": { device.config.position = config.payloadVariant.value; break; }
+ case "power": { device.config.power = config.payloadVariant.value; break; }
+ case "network": { device.config.network = config.payloadVariant.value; break; }
+ case "display": { device.config.display = config.payloadVariant.value; break; }
+ case "lora": { device.config.lora = config.payloadVariant.value; break; }
+ case "bluetooth": { device.config.bluetooth = config.payloadVariant.value; break; }
+ case "security": { device.config.security = config.payloadVariant.value; }
}
}
}),
@@ -220,66 +182,20 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
const device = draft.devices.get(id);
-
if (device) {
switch (config.payloadVariant.case) {
- case "mqtt": {
- device.moduleConfig.mqtt = config.payloadVariant.value;
- break;
- }
- case "serial": {
- device.moduleConfig.serial = config.payloadVariant.value;
- break;
- }
- case "externalNotification": {
- device.moduleConfig.externalNotification =
- config.payloadVariant.value;
- break;
- }
- case "storeForward": {
- device.moduleConfig.storeForward =
- config.payloadVariant.value;
- break;
- }
- case "rangeTest": {
- device.moduleConfig.rangeTest =
- config.payloadVariant.value;
- break;
- }
- case "telemetry": {
- device.moduleConfig.telemetry =
- config.payloadVariant.value;
- break;
- }
- case "cannedMessage": {
- device.moduleConfig.cannedMessage =
- config.payloadVariant.value;
- break;
- }
- case "audio": {
- device.moduleConfig.audio = config.payloadVariant.value;
- break;
- }
- case "neighborInfo": {
- device.moduleConfig.neighborInfo =
- config.payloadVariant.value;
- break;
- }
- case "ambientLighting": {
- device.moduleConfig.ambientLighting =
- config.payloadVariant.value;
- break;
- }
- case "detectionSensor": {
- device.moduleConfig.detectionSensor =
- config.payloadVariant.value;
- break;
- }
- case "paxcounter": {
- device.moduleConfig.paxcounter =
- config.payloadVariant.value;
- break;
- }
+ case "mqtt": { device.moduleConfig.mqtt = config.payloadVariant.value; break; }
+ case "serial": { device.moduleConfig.serial = config.payloadVariant.value; break; }
+ case "externalNotification": { device.moduleConfig.externalNotification = config.payloadVariant.value; break; }
+ case "storeForward": { device.moduleConfig.storeForward = config.payloadVariant.value; break; }
+ case "rangeTest": { device.moduleConfig.rangeTest = config.payloadVariant.value; break; }
+ case "telemetry": { device.moduleConfig.telemetry = config.payloadVariant.value; break; }
+ case "cannedMessage": { device.moduleConfig.cannedMessage = config.payloadVariant.value; break; }
+ case "audio": { device.moduleConfig.audio = config.payloadVariant.value; break; }
+ case "neighborInfo": { device.moduleConfig.neighborInfo = config.payloadVariant.value; break; }
+ case "ambientLighting": { device.moduleConfig.ambientLighting = config.payloadVariant.value; break; }
+ case "detectionSensor": { device.moduleConfig.detectionSensor = config.payloadVariant.value; break; }
+ case "paxcounter": { device.moduleConfig.paxcounter = config.payloadVariant.value; break; }
}
}
}),
@@ -289,40 +205,30 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const workingConfigIndex = device?.workingConfig.findIndex(
+ if (!device) return;
+ const index = device.workingConfig.findIndex(
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
);
- if (workingConfigIndex !== -1) {
- device.workingConfig[workingConfigIndex] = config;
+ if (index !== -1) {
+ device.workingConfig[index] = config;
} else {
- device?.workingConfig.push(config);
+ device.workingConfig.push(config);
}
}),
);
},
- setWorkingModuleConfig: (
- moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
- ) => {
+ setWorkingModuleConfig: (moduleConfig: Protobuf.ModuleConfig.ModuleConfig) => {
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const workingModuleConfigIndex = device?.workingModuleConfig
- .findIndex(
- (wmc) =>
- wmc.payloadVariant.case ===
- moduleConfig.payloadVariant.case,
- );
- if (workingModuleConfigIndex !== -1) {
- device.workingModuleConfig[workingModuleConfigIndex] =
- moduleConfig;
+ if (!device) return;
+ const index = device.workingModuleConfig.findIndex(
+ (wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
+ );
+ if (index !== -1) {
+ device.workingModuleConfig[index] = moduleConfig;
} else {
- device?.workingModuleConfig.push(moduleConfig);
+ device.workingModuleConfig.push(moduleConfig);
}
}),
);
@@ -337,50 +243,6 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
- // setMetrics: (metrics: Types.PacketMetadata) => {
- // set(
- // produce((draft) => {
- // const device = draft.devices.get(id);
- // let node = device?.nodes.find(
- // (n) => n.data.num === metrics.from
- // );
- // if (node) {
- // switch (metrics.data.variant.case) {
- // case "deviceMetrics":
- // if (device) {
- // if (metrics.data.variant.value.batteryLevel) {
- // device.currentMetrics.batteryLevel =
- // metrics.data.variant.value.batteryLevel;
- // }
- // if (metrics.data.variant.value.voltage) {
- // device.currentMetrics.voltage =
- // metrics.data.variant.value.voltage;
- // }
- // if (metrics.data.variant.value.airUtilTx) {
- // device.currentMetrics.airUtilTx =
- // metrics.data.variant.value.airUtilTx;
- // }
- // if (metrics.data.variant.value.channelUtilization) {
- // device.currentMetrics.channelUtilization =
- // metrics.data.variant.value.channelUtilization;
- // }
- // }
- // node.deviceMetrics.push({
- // metric: metrics.data.variant.value,
- // timestamp: metrics.rxTime
- // });
- // break;
- // case "environmentMetrics":
- // node.environmentMetrics.push({
- // metric: metrics.data.variant.value,
- // timestamp: metrics.rxTime
- // });
- // break;
- // }
- // }
- // })
- // );
- // },
setActivePage: (page) => {
set(
produce((draft) => {
@@ -405,10 +267,9 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- return;
+ if (device) {
+ device.channels.set(channel.index, channel);
}
- device.channels.set(channel.index, channel);
}),
);
},
@@ -417,12 +278,9 @@ export const useDeviceStore = createStore((set, get) => ({
produce((draft) => {
const device = draft.devices.get(id);
if (device) {
- const waypointIndex = device.waypoints.findIndex(
- (wp) => wp.id === waypoint.id,
- );
-
- if (waypointIndex !== -1) {
- device.waypoints[waypointIndex] = waypoint;
+ const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id);
+ if (index !== -1) {
+ device.waypoints[index] = waypoint;
} else {
device.waypoints.push(waypoint);
}
@@ -434,10 +292,9 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- device.nodes.set(nodeInfo.num, nodeInfo);
+
+ if (!device) return;
+ device.nodesMap.set(nodeInfo.num, nodeInfo);
}),
);
},
@@ -458,11 +315,11 @@ export const useDeviceStore = createStore((set, get) => ({
if (!device) {
return;
}
- const currentNode = device.nodes.get(user.from) ??
- create(Protobuf.Mesh.NodeInfoSchema);
+ const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
currentNode.user = user.data;
- device.nodes.set(user.from, currentNode);
- }),
+ currentNode.num = user.from;
+ device.nodesMap.set(user.from, currentNode);
+ })
);
},
addPosition: (position) => {
@@ -472,11 +329,11 @@ export const useDeviceStore = createStore((set, get) => ({
if (!device) {
return;
}
- const currentNode = device.nodes.get(position.from) ??
- create(Protobuf.Mesh.NodeInfoSchema);
+ const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
currentNode.position = position.data;
- device.nodes.set(position.from, currentNode);
- }),
+ currentNode.num = position.from;
+ device.nodesMap.set(position.from, currentNode);
+ })
);
},
addConnection: (connection) => {
@@ -493,10 +350,9 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- return;
+ if (device) {
+ device.metadata.set(from, metadata);
}
- device.metadata.set(from, metadata);
}),
);
},
@@ -504,17 +360,10 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- return;
- }
-
- const nodetraceroutes = device.traceroutes.get(traceroute.from);
- if (nodetraceroutes) {
- nodetraceroutes.push(traceroute);
- device.traceroutes.set(traceroute.from, nodetraceroutes);
- } else {
- device.traceroutes.set(traceroute.from, [traceroute]);
- }
+ if (!device) return;
+ const routes = device.traceroutes.get(traceroute.from) ?? [];
+ routes.push(traceroute);
+ device.traceroutes.set(traceroute.from, routes);
}),
);
},
@@ -523,46 +372,38 @@ export const useDeviceStore = createStore((set, get) => ({
produce((draft) => {
const device = draft.devices.get(id);
if (!device) {
- return;
+ return
}
- device.nodes.delete(nodeNum);
- }),
- );
+ device.nodesMap.delete(nodeNum);
+ }))
},
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- return;
+ if (device) {
+ device.dialog[dialog] = open;
}
- device.dialog[dialog] = open;
}),
);
},
getDialogOpen: (dialog: DialogVariant) => {
const device = get().devices.get(id);
- if (!device) {
- throw new Error("Device not found");
- }
+ if (!device) throw new Error(`Device ${id} not found`);
return device.dialog[dialog];
},
processPacket(data: ProcessPacketParams) {
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const node = device.nodes.get(data.from);
+ if (!device) return;
+ const node = device.nodesMap.get(data.from);
if (node) {
- device.nodes.set(data.from, {
- ...node,
- lastHeard: data.time,
- snr: data.snr,
- });
+ node.lastHeard = data.time;
+ node.snr = data.snr;
+ device.nodesMap.set(data.from, node);
} else {
- device.nodes.set(
+ device.nodesMap.set(
data.from,
create(Protobuf.Mesh.NodeInfoSchema, {
num: data.from,
@@ -606,26 +447,19 @@ export const useDeviceStore = createStore((set, get) => ({
},
getNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
- if (!device) {
- throw new Error("Device not found");
- }
+ if (!device) throw new Error(`Device ${id} not found`);
return device.nodeErrors.get(nodeNum);
},
hasNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
- if (!device) {
- throw new Error("Device not found");
- }
+ if (!device) throw new Error(`Device ${id} not found`);
return device.nodeErrors.has(nodeNum);
},
incrementUnread: (nodeNum: number) => {
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- console.warn(`incrementUnread: Device with ID ${id} not found.`);
- return;
- }
+ if (!device) return;
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
device.unreadCounts.set(nodeNum, currentCount + 1);
})
@@ -635,29 +469,54 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
const device = draft.devices.get(id);
- if (!device) {
- console.warn(`resetUnread: Device with ID ${id} not found.`);
- return;
- }
+ if (!device) return;
device.unreadCounts.set(nodeNum, 0);
-
if (device.unreadCounts.get(nodeNum) === 0) {
device.unreadCounts.delete(nodeNum);
}
})
);
},
+ getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean): Protobuf.Mesh.NodeInfo[] => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return [];
+ }
+ const allNodes = Array.from(device.nodesMap.values()).filter(
+ (node) => node.num !== get().devices.get(id)?.hardware.myNodeNum);
+ if (filter) {
+ return allNodes.filter(filter);
+ }
+ return allNodes;
+ },
+ getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return;
+ }
+ if (!device.nodesMap.has(nodeNum)) {
+ return undefined;
+ }
+ return device.nodesMap.get(nodeNum);
+ },
+ getNodesLength: () => {
+ const device = get().devices.get(id);
+ if (!device) {
+ return 0;
+ }
+ return device.nodesMap.size
+ },
});
}),
);
const device = get().devices.get(id);
-
if (!device) {
- throw new Error("Device not found");
+ throw new Error(`Failed to create or retrieve device with ID ${id}`);
}
return device;
},
+
removeDevice: (id) => {
set(
produce((draft) => {
diff --git a/src/core/stores/sidebarStore.tsx b/src/core/stores/sidebarStore.tsx
new file mode 100644
index 00000000..0c72836b
--- /dev/null
+++ b/src/core/stores/sidebarStore.tsx
@@ -0,0 +1,37 @@
+import React, { createContext, useState, useContext, useMemo } from 'react';
+
+interface SidebarContextProps {
+ isCollapsed: boolean;
+ setIsCollapsed: React.Dispatch>;
+ toggleSidebar: () => void;
+}
+
+const SidebarContext = createContext(undefined);
+
+export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [isCollapsed, setIsCollapsed] = useState(false);
+
+ const toggleSidebar = useMemo(() => () => {
+ setIsCollapsed(prev => !prev);
+ }, []);
+
+ const value = useMemo(() => ({
+ isCollapsed,
+ setIsCollapsed,
+ toggleSidebar,
+ }), [isCollapsed, toggleSidebar]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useSidebar = (): SidebarContextProps => {
+ const context = useContext(SidebarContext);
+ if (context === undefined) {
+ throw new Error('useSidebar must be used within a SidebarProvider');
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/src/core/subscriptions.ts b/src/core/subscriptions.ts
index 822efafa..09c3eda6 100644
--- a/src/core/subscriptions.ts
+++ b/src/core/subscriptions.ts
@@ -2,6 +2,7 @@ import type { Device } from "@core/stores/deviceStore.ts";
import { MeshDevice, Protobuf } from "@meshtastic/core";
import { MessageType, type MessageStore } from "@core/stores/messageStore.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
+import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
export const subscribeAll = (
device: Device,
@@ -10,9 +11,6 @@ export const subscribeAll = (
) => {
let myNodeNum = 0;
- // onLogEvent
- // onMeshHeartbeat
-
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
device.addMetadata(metadataPacket.from, metadataPacket.data);
});
@@ -67,10 +65,8 @@ export const subscribeAll = (
});
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
- // toast(`New Node Discovered: ${nodeInfo.user?.shortName ?? "UNK"}`, {
- // icon: "🔎"
- // });
- device.addNodeInfo(nodeInfo);
+ const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
+ device.addNodeInfo(nodeWithUser);
});
connection.events.onChannelPacket.subscribe((channel) => {
@@ -126,12 +122,14 @@ export const subscribeAll = (
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
- device.setDialogOpen("refreshKeys", true);
+ // TODO: this has be refactored as it isn't working properly
+ // device.setDialogOpen("refreshKeys", true);
break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
- device.setDialogOpen("refreshKeys", true);
+ // TODO: this has be refactored as it isn't working properly
+ // device.setDialogOpen("refreshKeys", true);
break;
default: {
break;
diff --git a/src/core/utils/string.ts b/src/core/utils/string.ts
index 2cabf70d..359b360e 100644
--- a/src/core/utils/string.ts
+++ b/src/core/utils/string.ts
@@ -29,3 +29,43 @@ export function formatQuantity(
return `${numberFormat.format(value)} ${word}`;
}
+
+export interface LengthValidationResult {
+ isValid: boolean;
+ currentLength: number | null;
+}
+
+export function validateMaxByteLength(value: string | null | undefined, maxByteLength: number): LengthValidationResult {
+ // Ensure maxByteLength is valid
+ if (typeof maxByteLength !== 'number' || !Number.isInteger(maxByteLength) || maxByteLength < 0) {
+ console.warn('validateMaxByteLength: maxByteLength must be a non-negative integer.');
+ return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
+ }
+
+ // Handle null or undefined input values
+ if (value === null || value === undefined) {
+ return { isValid: false, currentLength: null };
+ }
+
+ // Check for TextEncoder availability
+ if (typeof TextEncoder === 'undefined') {
+ console.error('validateMaxByteLength: TextEncoder API is not available in this environment.');
+ return { isValid: false, currentLength: null }; // Cannot determine byte length
+ }
+
+ try {
+ // Encode the string to UTF-8 bytes and get the length
+ const encoder = new TextEncoder();
+ const currentLength = encoder.encode(value).length;
+ // Perform the byte length check
+ const isValid = currentLength <= maxByteLength;
+
+ // Return the result object
+ return { isValid, currentLength };
+ } catch (error) {
+ // Handle potential errors during encoding
+ console.error('validateMaxByteLength: Error encoding string:', error);
+ return { isValid: false, currentLength: null }; // Encoding failed
+ }
+}
+
diff --git a/src/index.css b/src/index.css
index d81c2967..6802cff8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -25,6 +25,7 @@
--brightness-hover: var(--brightnessHover);
--brightness-press: var(--brightnessPress);
--brightness-disabled: var(--brightnessDisabled);
+ --sidebar-width: @apply w-50 lg:w-64;
}
[data-theme="light"] {
diff --git a/src/pages/Channels.tsx b/src/pages/Channels.tsx
index 66dea48b..fbc033bc 100644
--- a/src/pages/Channels.tsx
+++ b/src/pages/Channels.tsx
@@ -17,8 +17,8 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) =>
channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
- ? "Primary"
- : `Ch ${channel.index}`;
+ ? "Primary"
+ : `Ch ${channel.index}`;
const ChannelsPage = () => {
const { channels, setDialogOpen } = useDevice();
@@ -31,19 +31,20 @@ const ChannelsPage = () => {
return (
<>
-
}
+ label={`Channel: ${currentChannel ? getChannelName(currentChannel) : "Loading..."
+ }`}
actions={[
{
+ key: "search",
icon: ImportIcon,
onClick() {
setDialogOpen("import", true);
},
},
{
+ key: "import",
icon: QrCodeIcon,
onClick() {
setDialogOpen("QR", true);
diff --git a/src/pages/Config/index.tsx b/src/pages/Config/index.tsx
index 96a4e279..d69c4116 100644
--- a/src/pages/Config/index.tsx
+++ b/src/pages/Config/index.tsx
@@ -8,7 +8,7 @@ import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
-import { useState } from "react";
+import { useMemo, useState } from "react";
const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice();
@@ -51,11 +51,14 @@ const ConfigPage = () => {
description:
`The configuration change ${moduleConfig.payloadVariant.case} has been saved.`,
})
+
)
),
);
+ setIsSaving(false);
}
await connection?.commitEditSettings();
+
} catch (_error) {
toast({
title: "Error Saving Config",
@@ -66,8 +69,8 @@ const ConfigPage = () => {
}
};
- return (
- <>
+ const leftSidebar = useMemo(
+ () => (
{
/>
+ ), [])
+
+ return (
+ <>
+
{
- const { nodes, waypoints } = useDevice();
+ const { getNodes, waypoints } = useDevice();
const { theme } = useTheme();
const { default: map } = useMap();
@@ -48,11 +48,11 @@ const MapPage = () => {
// Filter out nodes without a valid position
const validNodes = useMemo(
() =>
- Array.from(nodes.values()).filter(
+ getNodes(
(node): node is Protobuf.Mesh.NodeInfo =>
Boolean(node.position?.latitudeI),
),
- [nodes],
+ [getNodes],
);
const {
@@ -149,8 +149,7 @@ const MapPage = () => {
return (
<>
-
-
+ }>
{
- const { channels, nodes, hardware, hasNodeError, unreadCounts, resetUnread } = useDevice();
+ const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread } = useDevice();
const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
const { toast } = useToast();
+ const { isCollapsed } = useSidebar()
const [searchTerm, setSearchTerm] = useState("");
- const filteredNodes: NodeInfoWithUnread[] = Array.from(nodes.values())
- .filter((node) => node.num !== hardware.myNodeNum)
- .map((node) => ({
- ...node,
- unreadCount: unreadCounts.get(node.num) ?? 0,
- }))
- .filter((node) => {
- const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
- return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
+ const filteredNodes: NodeInfoWithUnread[] = useMemo(() => {
+ const lowerCaseSearchTerm = searchTerm.toLowerCase();
+
+ return getNodes(node => {
+ const longName = node.user?.longName?.toLowerCase() ?? '';
+ const shortName = node.user?.shortName?.toLowerCase() ?? '';
+ return longName.includes(lowerCaseSearchTerm) || shortName.includes(lowerCaseSearchTerm)
})
- .sort((a, b) => b.unreadCount - a.unreadCount);
+ .map((node) => ({
+ ...node,
+ unreadCount: unreadCounts.get(node.num) ?? 0,
+ }))
+ .sort((a, b) => b.unreadCount - a.unreadCount);
+ }, [getNodes, searchTerm, unreadCounts]);
const allChannels = Array.from(channels.values());
@@ -42,140 +47,143 @@ export const MessagesPage = () => {
);
const currentChannel = channels.get(activeChat);
- const otherNode = nodes.get(activeChat);
-
- const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
+ const otherNode = getNode(activeChat);
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const currentChat = { type: chatType, id: activeChat };
- return (
- <>
-
-
- {filteredChannels.map((channel) => (
- {
- setChatType(MessageType.Broadcast);
- setActiveChat(channel.index);
- resetUnread(channel.index);
- }}
- >
-
-
- ))}
-
-
-
- setSearchTerm(e.target.value)}
- className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
- />
-
-
- {filteredNodes.map((node) => (
-
0 ? node.unreadCount : undefined}
- active={activeChat === node.num && chatType === MessageType.Direct}
- onClick={() => {
- setChatType(MessageType.Direct);
- setActiveChat(node.num);
- resetUnread(node.num);
- }}>
-
-
- ))}
+ const renderChatContent = () => {
+ switch (chatType) {
+ case MessageType.Broadcast:
+ return (
+
+ );
+ case MessageType.Direct:
+ return (
+
+ );
+ default:
+ return (
+
+ Select a channel or node to start messaging.
-
-
-
-
-
- {isBroadcast && currentChannel && (
-
- )}
+ );
+ }
+ }
- {isDirect && otherNode && (
-
- )}
+ const leftSidebar = useMemo(() => (
+
+
+ {filteredChannels?.map((channel) => (
+ {
+ setChatType(MessageType.Broadcast);
+ setActiveChat(channel.index);
+ resetUnread(channel.index);
+ }}
+ >
+
+
+ ))}
+
+
+ ), [filteredChannels, unreadCounts, activeChat, chatType, isCollapsed, setActiveChat, setChatType, resetUnread]);
- {!isBroadcast && !isDirect && (
-
- Select a channel or node to start messaging.
-
- )}
-
+ const rightSidebar = useMemo(() => (
+
+
+
+ {filteredNodes?.map((node) => (
+
0 ? node.unreadCount : undefined}
+ active={activeChat === node.num && chatType === MessageType.Direct}
+ onClick={() => {
+ setChatType(MessageType.Direct);
+ setActiveChat(node.num);
+ resetUnread(node.num);
+ }}>
+
+
+ ))}
+
+
+ ), [filteredNodes, searchTerm, activeChat, chatType, setActiveChat, setChatType, resetUnread, hasNodeError]);
-
- {(isBroadcast || isDirect) ? (
-
- ) : (
-
Select a chat to send a message.
- )}
-
-
+ return (
+
+
+ {renderChatContent()}
+
+
+ {(isBroadcast || isDirect) ? (
+
+ ) : (
+
Select a chat to send a message.
+ )}
+
- >
+
);
};
diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx
index 371e53c3..50723361 100644
--- a/src/pages/Nodes.tsx
+++ b/src/pages/Nodes.tsx
@@ -13,27 +13,29 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
import { type JSX, useCallback, useEffect, useState } from "react";
import { base16 } from "rfc4648";
+import { Input } from "@components/UI/Input.tsx";
+import { PageLayout } from "@components/PageLayout.tsx";
export interface DeleteNoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
-function shortNameFromNode(
- node: ReturnType
["nodes"][number],
-): string {
- const shortNameOfNode = node.user?.shortName ??
- (node.user?.macaddr
- ? `${base16
- .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
- .toLowerCase()
- }`
- : `${numberToHexUnpadded(node.num).slice(-4)}`);
- return String(shortNameOfNode);
-}
+// function shortNameFromNode(
+// node: ReturnType["nodes"][number],
+// ): string {
+// const shortNameOfNode = node.user?.shortName ??
+// (node.user?.macaddr
+// ? `${base16
+// .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
+// .toLowerCase()
+// }`
+// : `${numberToHexUnpadded(node.num).slice(-4)}`);
+// return String(shortNameOfNode);
+// }
const NodesPage = (): JSX.Element => {
- const { nodes, hardware, connection } = useDevice();
+ const { getNodes, hardware, connection } = useDevice();
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined
>(undefined);
@@ -45,11 +47,7 @@ const NodesPage = (): JSX.Element => {
>();
const [searchTerm, setSearchTerm] = useState("");
- const filteredNodes = Array.from(nodes.values()).filter((node) => {
- if (node.num === hardware.myNodeNum) return false;
- const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
- return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
- });
+ const filteredNodes = getNodes()
useEffect(() => {
if (!connection) return;
@@ -84,18 +82,16 @@ const NodesPage = (): JSX.Element => {
return (
<>
-
-