Browse Source

refactor nodes to getNodes fn. ui updates

pull/586/head
Dan Ditomaso 1 year ago
parent
commit
1900995e7a
  1. 1
      deno.lock
  2. 16
      public/Logo.svg
  3. 16
      src/App.tsx
  4. 10
      src/components/CommandPalette/index.tsx
  5. 76
      src/components/DeviceSelector.tsx
  6. 25
      src/components/DeviceSelectorButton.tsx
  7. 81
      src/components/Dialog/DeviceNameDialog.tsx
  8. 4
      src/components/Dialog/LocationResponseDialog.tsx
  9. 4
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  10. 5
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  11. 4
      src/components/Dialog/RemoveNodeDialog.tsx
  12. 6
      src/components/Dialog/TracerouteResponseDialog.tsx
  13. 5
      src/components/PageComponents/Config/Security/Security.tsx
  14. 5
      src/components/PageComponents/Map/NodeDetail.tsx
  15. 46
      src/components/PageComponents/Messages/ChannelChat.tsx
  16. 2
      src/components/PageComponents/Messages/MessageActionsMenu.tsx
  17. 6
      src/components/PageComponents/Messages/MessageInput.tsx
  18. 19
      src/components/PageComponents/Messages/MessageItem.tsx
  19. 6
      src/components/PageComponents/Messages/TraceRoute.tsx
  20. 116
      src/components/PageLayout.tsx
  21. 286
      src/components/Sidebar.tsx
  22. 10
      src/components/ThemeSwitcher.tsx
  23. 2
      src/components/UI/Avatar.tsx
  24. 43
      src/components/UI/Sidebar/SidebarSection.tsx
  25. 54
      src/components/UI/Sidebar/sidebarButton.tsx
  26. 2
      src/components/UI/Typography/Heading.tsx
  27. 40
      src/core/dto/NodeNumToNodeInfoDTO.ts
  28. 367
      src/core/stores/deviceStore.ts
  29. 37
      src/core/stores/sidebarStore.tsx
  30. 16
      src/core/subscriptions.ts
  31. 40
      src/core/utils/string.ts
  32. 1
      src/index.css
  33. 7
      src/pages/Channels.tsx
  34. 15
      src/pages/Config/index.tsx
  35. 9
      src/pages/Map/index.tsx
  36. 138
      src/pages/Messages.tsx
  37. 62
      src/pages/Nodes.tsx

1
deno.lock

@ -6468,6 +6468,7 @@
"npm:@radix-ui/react-scroll-area@^1.2.3", "npm:@radix-ui/react-scroll-area@^1.2.3",
"npm:@radix-ui/react-select@^2.1.6", "npm:@radix-ui/react-select@^2.1.6",
"npm:@radix-ui/react-separator@^1.1.2", "npm:@radix-ui/react-separator@^1.1.2",
"npm:@radix-ui/react-slider@^1.3.2",
"npm:@radix-ui/react-switch@^1.1.3", "npm:@radix-ui/react-switch@^1.1.3",
"npm:@radix-ui/react-tabs@^1.1.3", "npm:@radix-ui/react-tabs@^1.1.3",
"npm:@radix-ui/react-toast@^1.2.6", "npm:@radix-ui/react-toast@^1.2.6",

16
public/Logo.svg

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="512" height="512" viewBox="0 0 512 512" xml:space="preserve">
<desc>Created with Fabric.js 4.6.0</desc>
<defs>
</defs>
<g transform="matrix(1 0 0 1 256 256)" id="xYQ9Gk9Jwpgj_HMOXB3F_" >
<path style="stroke: rgb(213,130,139); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(103,234,148); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-256, -256)" d="M 0 0 L 512 0 L 512 512 L 0 512 z" stroke-linecap="round" />
</g>
<g transform="matrix(1.79 0 0 1.79 313.74 258.36)" id="1xBsk2n9FZp60Rz1O-ceJ" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-250.97, -362.41)" d="M 250.908 330.267 L 193.126 415.005 L 180.938 406.694 L 244.802 313.037 C 246.174 311.024 248.453 309.819 250.889 309.816 C 253.326 309.814 255.606 311.015 256.982 313.026 L 320.994 406.536 L 308.821 414.869 L 250.908 330.267 Z" stroke-linecap="round" />
</g>
<g transform="matrix(1.81 0 0 1.81 145 256.15)" id="KxN7E9YpbyPgz0S4z4Cl6" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-115.14, -528.06)" d="M 87.642 581.398 L 154.757 482.977 L 142.638 474.713 L 75.523 573.134 L 87.642 581.398 Z" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

16
src/App.tsx

@ -1,6 +1,5 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx"; import { PageRouter } from "@app/PageRouter.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
@ -14,6 +13,7 @@ import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import { MapProvider } from "react-map-gl/maplibre"; import { MapProvider } from "react-map-gl/maplibre";
import { CommandPalette } from "@components/CommandPalette/index.tsx"; import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { SidebarProvider } from "@core/stores/sidebarStore.tsx";
export const App = (): JSX.Element => { export const App = (): JSX.Element => {
@ -23,6 +23,7 @@ export const App = (): JSX.Element => {
const device = getDevice(selectedDevice); const device = getDevice(selectedDevice);
return ( return (
<ErrorBoundary FallbackComponent={ErrorPage}> <ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog <NewDeviceDialog
@ -33,12 +34,11 @@ export const App = (): JSX.Element => {
/> />
<Toaster /> <Toaster />
<DeviceWrapper device={device}> <DeviceWrapper device={device}>
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary"> <div className="flex h-screen flex-col bg-background-primary text-text-primary">
<div className="flex grow"> <SidebarProvider>
<DeviceSelector /> <div className="h-full flex flex-col">
<div className="flex grow flex-col">
{device ? ( {device ? (
<div className="flex h-screen w-full"> <div className="h-full flex w-full">
<DialogManager /> <DialogManager />
<KeyBackupReminder /> <KeyBackupReminder />
<CommandPalette /> <CommandPalette />
@ -53,9 +53,9 @@ export const App = (): JSX.Element => {
</> </>
)} )}
</div> </div>
</div> </SidebarProvider>
</div> </div>
</DeviceWrapper> </DeviceWrapper>
</ErrorBoundary> </ErrorBoundary >
); );
}; };

10
src/components/CommandPalette/index.tsx

@ -60,7 +60,7 @@ export const CommandPalette = () => {
setSelectedDevice, setSelectedDevice,
} = useAppStore(); } = useAppStore();
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { setDialogOpen, setActivePage, connection } = useDevice(); const { setDialogOpen, setActivePage, getNode, connection } = useDevice();
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' }); const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
const groups: Group[] = [ const groups: Group[] = [
@ -115,12 +115,12 @@ export const CommandPalette = () => {
icon: ArrowLeftRightIcon, icon: ArrowLeftRightIcon,
subItems: getDevices().map((device) => ({ subItems: getDevices().map((device) => ({
label: label:
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? getNode(device.hardware.myNodeNum)?.user?.longName ??
device.hardware.myNodeNum.toString(), device.hardware.myNodeNum.toString(),
icon: ( icon: (
<Avatar <Avatar
text={ text={
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ?? getNode(device.hardware.myNodeNum)?.user?.shortName ??
device.hardware.myNodeNum.toString() device.hardware.myNodeNum.toString()
} }
/> />
@ -222,7 +222,7 @@ export const CommandPalette = () => {
label: "Clear All Stored Message", label: "Clear All Stored Message",
icon: EraserIcon, icon: EraserIcon,
action() { action() {
setDialogOpen("clearMessages", true); setDialogOpen("deleteMessages", true);
}, },
}, },
], ],
@ -262,7 +262,7 @@ export const CommandPalette = () => {
type="button" type="button"
onClick={() => togglePinnedItem(group.label)} onClick={() => togglePinnedItem(group.label)}
className={cn( className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100" "transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100"
)} )}
aria-description={ aria-description={
pinnedItems.includes(group.label) pinnedItems.includes(group.label)

76
src/components/DeviceSelector.tsx

@ -1,76 +0,0 @@
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Code } from "@components/UI/Typography/Code.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react";
import { Avatar } from "@components/UI/Avatar.tsx";
export const DeviceSelector = () => {
const { getDevices } = useDeviceStore();
const {
selectedDevice,
setSelectedDevice,
setCommandPaletteOpen,
setConnectDialogOpen,
} = useAppStore();
return (
<nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 pt-2 dark:border-slate-700">
<div className="flex flex-col overflow-y-hidden">
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5">
<DeviceSelectorButton
active={selectedDevice === 0}
onClick={() => {
setSelectedDevice(0);
}}
>
<HomeIcon />
</DeviceSelectorButton>
{getDevices().map((device) => (
<DeviceSelectorButton
key={device.id}
onClick={() => {
setSelectedDevice(device.id);
}}
active={selectedDevice === device.id}
>
<Avatar
text={device.nodes
.get(device.hardware.myNodeNum)
?.user?.shortName.toString() ?? "UNK"}
/>
</DeviceSelectorButton>
))}
<Separator />
<button
type="button"
onClick={() => setConnectDialogOpen(true)}
className="transition-all duration-300"
>
<PlusIcon />
</button>
</ul>
</div>
<div className="flex w-20 flex-col items-center space-y-5 px-5 pb-5">
<ThemeSwitcher />
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setCommandPaletteOpen(true)}
>
<SearchIcon />
</button>
{/* TODO: This is being commented out until its fixed */}
{
/* <button type="button" className="transition-all hover:text-accent">
<LanguagesIcon />
</button> */
}
<Separator />
<Code>{import.meta.env.VITE_COMMIT_HASH}</Code>
</div>
</nav>
);
};

25
src/components/DeviceSelectorButton.tsx

@ -1,25 +0,0 @@
export interface DeviceSelectorButtonProps {
active: boolean;
onClick: () => void;
children?: React.ReactNode;
}
export const DeviceSelectorButton = ({
onClick,
children,
}: DeviceSelectorButtonProps) => (
<li
className="aspect-w-1 aspect-h-1 relative w-full"
onClick={onClick}
onKeyDown={onClick}
>
{
/* {active && (
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" />
)} */
}
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center">
{children}
</div>
</li>
);

81
src/components/Dialog/DeviceNameDialog.tsx

@ -10,10 +10,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx"; import { Label } from "@components/UI/Label.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { GenericInput } from "@components/Form/FormInput.tsx";
import { validateMaxByteLength } from "@core/utils/string.ts";
export interface User { export interface User {
longName: string; longName: string;
@ -24,32 +25,44 @@ export interface DeviceNameDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
const MAX_LONG_NAME_BYTE_LENGTH = 40;
const MAX_SHORT_NAME_BYTE_LENGTH = 4;
export const DeviceNameDialog = ({ export const DeviceNameDialog = ({
open, open,
onOpenChange, onOpenChange,
}: DeviceNameDialogProps) => { }: DeviceNameDialogProps) => {
const { hardware, nodes, connection } = useDevice(); const { hardware, getNode, connection } = useDevice();
const myNode = getNode(hardware.myNodeNum);
const myNode = nodes.get(hardware.myNodeNum); const defaultValues = {
const { register, handleSubmit } = useForm<User>({
values: {
longName: myNode?.user?.longName ?? "Unknown", longName: myNode?.user?.longName ?? "Unknown",
shortName: myNode?.user?.shortName ?? "Unknown", shortName: myNode?.user?.shortName ?? "??",
}, };
const { getValues, setValue, reset, control, handleSubmit } = useForm<User>({
values: defaultValues,
}); });
const { currentLength: currentLongNameLength } = validateMaxByteLength(getValues('longName'), MAX_LONG_NAME_BYTE_LENGTH);
const { currentLength: currentShortNameLength } = validateMaxByteLength(getValues('shortName'), MAX_SHORT_NAME_BYTE_LENGTH);
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
connection?.setOwner( connection?.setOwner(
create(Protobuf.Mesh.UserSchema, { create(Protobuf.Mesh.UserSchema, {
...myNode?.user, ...(myNode?.user ?? {}),
...data, ...data,
}), }),
); );
onOpenChange(false); onOpenChange(false);
}); });
const handleReset = () => {
reset({ longName: "", shortName: "" });
setValue("longName", "");
setValue("shortName", "");
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
@ -60,21 +73,49 @@ export const DeviceNameDialog = ({
The Device will restart once the config is saved. The Device will restart once the config is saved.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="gap-4"> <form onSubmit={onSubmit} className="flex flex-col gap-4">
<form onSubmit={onSubmit}> <div>
<Label>Long Name</Label> <Label htmlFor="longName">Long Name</Label>
<Input className="dark:text-slte-900" {...register("longName")} /> <GenericInput
<Label>Short Name</Label> control={control}
<Input field={{
className="dark:text-slte-900" name: "longName",
maxLength={4} label: "Long Name",
{...register("shortName")} type: "text",
properties: {
fieldLength: {
currentValueLength: currentLongNameLength ?? 0,
max: MAX_LONG_NAME_BYTE_LENGTH,
showCharacterCount: true,
},
},
}}
/> />
</form>
</div> </div>
<div>
<Label htmlFor="shortName">Short Name</Label>
<GenericInput
control={control}
field={{
name: "shortName",
label: "Short Name",
type: "text",
properties: {
fieldLength: {
currentValueLength: currentShortNameLength ?? 0,
max: MAX_SHORT_NAME_BYTE_LENGTH,
showCharacterCount: true,
},
},
}}
/>
</div>
<DialogFooter> <DialogFooter>
<Button onClick={() => onSubmit()}>Save</Button> <Button type="button" variant="destructive" onClick={handleReset}>Reset</Button>
<Button type="submit">Save</Button>
</DialogFooter> </DialogFooter>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

4
src/components/Dialog/LocationResponseDialog.tsx

@ -21,9 +21,9 @@ export const LocationResponseDialog = ({
open, open,
onOpenChange, onOpenChange,
}: LocationResponseDialogProps) => { }: LocationResponseDialogProps) => {
const { nodes } = useDevice(); const { getNode } = useDevice();
const from = nodes.get(location?.from ?? 0); const from = getNode(location?.from ?? 0);
const longName = from?.user?.longName ?? const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ?? const shortName = from?.user?.shortName ??

4
src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

@ -29,10 +29,10 @@ export const NodeDetailsDialog = ({
open, open,
onOpenChange, onOpenChange,
}: NodeDetailsDialogProps) => { }: NodeDetailsDialogProps) => {
const { nodes } = useDevice(); const { getNode } = useDevice();
const { nodeNumDetails } = useAppStore(); const { nodeNumDetails } = useAppStore();
const device = nodes.get(nodeNumDetails); const device = getNode(nodeNumDetails);
if (!device) return null; if (!device) return null;

5
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -18,17 +18,16 @@ export interface RefreshKeysDialogProps {
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => { export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
const { activeChat } = useMessageStore(); const { activeChat } = useMessageStore();
const { nodeErrors, nodes } = useDevice(); const { nodeErrors, getNode } = useDevice();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
const nodeErrorNum = nodeErrors.get(activeChat); const nodeErrorNum = nodeErrors.get(activeChat);
if (!nodeErrorNum) { if (!nodeErrorNum) {
console.error("Node with error not found");
return null; return null;
} }
const nodeWithError = nodes.get(nodeErrorNum?.node ?? 0); const nodeWithError = getNode(nodeErrorNum?.node ?? 0);
if (!nodeWithError) { if (!nodeWithError) {
console.error("Node with error not found"); console.error("Node with error not found");

4
src/components/Dialog/RemoveNodeDialog.tsx

@ -21,7 +21,7 @@ export const RemoveNodeDialog = ({
open, open,
onOpenChange, onOpenChange,
}: RemoveNodeDialogProps) => { }: RemoveNodeDialogProps) => {
const { connection, nodes, removeNode } = useDevice(); const { connection, getNode, removeNode } = useDevice();
const { nodeNumToBeRemoved } = useAppStore(); const { nodeNumToBeRemoved } = useAppStore();
const onSubmit = () => { const onSubmit = () => {
@ -42,7 +42,7 @@ export const RemoveNodeDialog = ({
</DialogHeader> </DialogHeader>
<div className="gap-4"> <div className="gap-4">
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<Label>{nodes.get(nodeNumToBeRemoved)?.user?.longName}</Label> <Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
</form> </form>
</div> </div>
<DialogFooter> <DialogFooter>

6
src/components/Dialog/TracerouteResponseDialog.tsx

@ -23,17 +23,17 @@ export const TracerouteResponseDialog = ({
open, open,
onOpenChange, onOpenChange,
}: TracerouteResponseDialogProps) => { }: TracerouteResponseDialogProps) => {
const { nodes } = useDevice(); const { getNode } = useDevice();
const route: number[] = traceroute?.data.route ?? []; const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? []; const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = (traceroute?.data.snrTowards ?? []).map(snr => snr / 4); const snrTowards = (traceroute?.data.snrTowards ?? []).map(snr => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map(snr => snr / 4); const snrBack = (traceroute?.data.snrBack ?? []).map(snr => snr / 4);
const from = nodes.get(traceroute?.from ?? 0); const from = getNode(traceroute?.from ?? 0);
const longName = from?.user?.longName ?? const longName = from?.user?.longName ??
(from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown");
const shortName = from?.user?.shortName ?? const shortName = from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); (from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK");
const to = nodes.get(traceroute?.to ?? 0); const to = getNode(traceroute?.to ?? 0);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>

5
src/components/PageComponents/Config/Security/Security.tsx

@ -270,11 +270,6 @@ export const Security = () => {
properties: { properties: {
value: state.adminKey, value: state.adminKey,
showCopyButton: true, showCopyButton: true,
action: {
icon: state.adminKeyVisible ? EyeOff : Eye,
onClick: () =>
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
},
}, },
}, },
], ],

5
src/components/PageComponents/Map/NodeDetail.tsx

@ -7,7 +7,6 @@ import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import type { Protobuf as ProtobufType } from "@meshtastic/core"; import type { Protobuf as ProtobufType } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { import {
BatteryChargingIcon, BatteryChargingIcon,
BatteryFullIcon, BatteryFullIcon,
@ -37,7 +36,7 @@ export interface NodeDetailProps {
export const NodeDetail = ({ node }: NodeDetailProps) => { export const NodeDetail = ({ node }: NodeDetailProps) => {
const { setChatType, setActiveChat } = useMessageStore(); const { setChatType, setActiveChat } = useMessageStore();
const { setActivePage } = useDevice(); const { setActivePage } = useDevice();
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`; const name = node.user?.longName ?? `UNK`;
const shortName = node.user?.shortName ?? "UNK"; const shortName = node.user?.shortName ?? "UNK";
const hwModel = node.user?.hwModel ?? 0; const hwModel = node.user?.hwModel ?? 0;
const hardwareType = const hardwareType =
@ -53,7 +52,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div className="dark:text-slate-900 p-1"> <div className="dark:text-slate-900 p-1">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1"> <div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={shortName} /> <Avatar text={shortName} size="sm" />
<div onFocusCapture={(e) => { <div onFocusCapture={(e) => {
// Required to prevent DM tooltip auto-appearing on creation // Required to prevent DM tooltip auto-appearing on creation

46
src/components/PageComponents/Messages/ChannelChat.tsx

@ -8,7 +8,7 @@ export interface ChannelChatProps {
} }
const EmptyState = () => ( const EmptyState = () => (
<div className="flex flex-col place-content-center place-items-center p-8 text-gray-500 dark:text-gray-400"> <div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-gray-500 dark:text-gray-400">
<InboxIcon className="h-8 w-8 mb-2" /> <InboxIcon className="h-8 w-8 mb-2" />
<span className="text-sm">No Messages</span> <span className="text-sm">No Messages</span>
</div> </div>
@ -18,55 +18,53 @@ export const ChannelChat = ({
messages = [], messages = [],
}: ChannelChatProps) => { }: ChannelChatProps) => {
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLUListElement>(null);
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (scrollContainer) { if (!scrollContainer) return;
const isNearBottom = const isNearBottom =
scrollContainer.scrollHeight - scrollContainer.scrollHeight -
scrollContainer.scrollTop - scrollContainer.scrollTop -
scrollContainer.clientHeight < scrollContainer.clientHeight < 100; // Threshold in pixels
100;
if (isNearBottom) { if (isNearBottom || behavior === 'instant') {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior });
}
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom('smooth');
}, [scrollToBottom, messages]); }, [messages, scrollToBottom]);
useEffect(() => {
scrollToBottom('instant');
}, [scrollToBottom]);
if (!messages?.length) { if (!messages?.length) {
return ( return (
<div className="flex flex-col h-full"> <ul ref={scrollContainerRef} className="flex-1 flex flex-col items-center justify-center">
<div className="flex-1 flex items-center justify-center">
<EmptyState /> <EmptyState />
</div> </ul>
</div>
); );
} }
return ( return (
<div className="flex flex-col h-full"> <ul
<div
ref={scrollContainerRef} ref={scrollContainerRef}
className="flex-1 overflow-y-auto py-4" className="flex-1 overflow-y-auto px-3 py-2"
> >
<div className="flex flex-col justify-end min-h-full space-y-4">
{messages?.map((message) => { {messages?.map((message) => {
return ( return (
<MessageItem <MessageItem
key={message?.messageId} key={message?.messageId ?? `${message?.from}-${message?.date}`}
message={message} message={message}
/> />
); );
})} })}
<div ref={messagesEndRef} className="h-0 w-full" /> <div ref={messagesEndRef} className="h-px" />
</div> </ul>
</div>
</div>
); );
}; };

2
src/components/PageComponents/Messages/MessageActionsMenu.tsx

@ -18,7 +18,7 @@ export const MessageActionsMenu = ({
onReply onReply
}: MessageActionsMenuProps) => { }: MessageActionsMenuProps) => {
const hoverIconBarClass = cn( const hoverIconBarClass = cn(
"absolute top-2 right-4", "absolute top-2 right-2",
"flex items-center gap-x-1", "flex items-center gap-x-1",
"bg-white dark:bg-zinc-800", "bg-white dark:bg-zinc-800",
"border border-gray-200 dark:border-zinc-600", "border border-gray-200 dark:border-zinc-600",

6
src/components/PageComponents/Messages/MessageInput.tsx

@ -75,8 +75,8 @@ export const MessageInput = ({
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}> <form className="w-full" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-2"> <div className="flex grow gap-1">
<label className="w-full"> <label className="w-full">
<Input <Input
autoFocus autoFocus
@ -88,7 +88,7 @@ export const MessageInput = ({
/> />
</label> </label>
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end"> <label data-testid="byte-counter" className="flex items-center w-20 p-1 text-sm place-content-end">
{messageBytes}/{maxBytes} {messageBytes}/{maxBytes}
</label> </label>

19
src/components/PageComponents/Messages/MessageItem.tsx

@ -5,7 +5,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@components/UI/Tooltip.tsx"; } from "@components/UI/Tooltip.tsx";
import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
@ -76,14 +76,13 @@ const TimeDisplay = ({ date, className }: { date: number; className?: string })
export const MessageItem = ({ message }: MessageProps) => { export const MessageItem = ({ message }: MessageProps) => {
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { getNode } = useDevice()
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => { const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
if (message?.from === null || message?.from === undefined) return null; if (message?.from === null || message?.from === undefined) return null;
const devices = getDevices(); const fromNode = getNode(message.from);
for (const device of devices) { if (fromNode) {
if (device.nodes.has(message.from)) { return fromNode ?? null;
return device.nodes.get(message.from) ?? null;
}
} }
return null; return null;
}, [getDevices, message.from]); }, [getDevices, message.from]);
@ -102,14 +101,14 @@ export const MessageItem = ({ message }: MessageProps) => {
const isFailed = message.state === MessageState.Failed; const isFailed = message.state === MessageState.Failed;
const messageItemWrapperClass = cn( const messageItemWrapperClass = cn(
"group w-full px-4 py-2 relative list-none", "group w-full px-2 py-2 relative list-none",
"rounded-md", "rounded-md",
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20", "hover:bg-slate-300/15 dark:hover:bg-slate-600/20",
"transition-colors duration-100 ease-in-out", "transition-colors duration-100 ease-in-out",
); );
const avatarSizeClass = "size-11"; // const avatarSizeClass = "size-11";
const gridGapClass = "gap-x-4"; const gridGapClass = "gap-x-2";
const baseTextStyle = "text-sm text-gray-800 dark:text-gray-200"; const baseTextStyle = "text-sm text-gray-800 dark:text-gray-200";
const nameTextStyle = "font-medium text-gray-900 dark:text-gray-100 mr-2"; const nameTextStyle = "font-medium text-gray-900 dark:text-gray-100 mr-2";
@ -120,7 +119,7 @@ export const MessageItem = ({ message }: MessageProps) => {
return ( return (
<li className={messageItemWrapperClass}> <li className={messageItemWrapperClass}>
<div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}> <div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}>
<Avatar size="sm" text={shortName} className={cn(avatarSizeClass, "pt-0.5")} /> <Avatar size="sm" text={shortName} className="pt-0.5" />
<div className="flex flex-col gap-1.5 min-w-0"> <div className="flex flex-col gap-1.5 min-w-0">
{messageDate != null ? ( {messageDate != null ? (

6
src/components/PageComponents/Messages/TraceRoute.tsx

@ -20,7 +20,7 @@ interface RoutePathProps {
} }
const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => { const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => {
const { nodes } = useDevice(); const { getNode } = useDevice();
return ( return (
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900"> <span className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
@ -28,8 +28,8 @@ const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) =>
<p>{startNode?.user?.longName}</p> <p>{startNode?.user?.longName}</p>
<p> {snr?.[0] ?? "??"}dB</p> <p> {snr?.[0] ?? "??"}dB</p>
{path.map((hop, i) => ( {path.map((hop, i) => (
<span key={nodes.get(hop)?.num ?? hop}> <span key={getNode(hop)?.num ?? hop}>
<p>{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p> <p>{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p>
<p> {snr?.[i + 1] ?? "??"}dB</p> <p> {snr?.[i + 1] ?? "??"}dB</p>
</span> </span>
))} ))}

116
src/components/PageLayout.tsx

@ -1,76 +1,124 @@
import React from 'react';
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { AlignLeftIcon, type LucideIcon } from "lucide-react"; import { type LucideIcon } from "lucide-react";
import Footer from "@components/UI/Footer.tsx"; import Footer from "@components/UI/Footer.tsx";
import { Spinner } from "@components/UI/Spinner.tsx"; import { Spinner } from "@components/UI/Spinner.tsx";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export interface ActionItem {
export interface PageLayoutProps { key: string;
label: string;
noPadding?: boolean;
children: React.ReactNode;
className?: string;
actions?: {
icon: LucideIcon; icon: LucideIcon;
iconClasses?: string; iconClasses?: string;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
}[]; ariaLabel?: string;
}
export interface PageLayoutProps {
label: string;
actions?: ActionItem[];
children: React.ReactNode;
leftBar?: React.ReactNode;
rightBar?: React.ReactNode;
className?: string;
noPadding?: boolean;
leftBarClassName?: string;
rightBarClassName?: string;
topBarClassName?: string;
} }
export const PageLayout = ({ export const PageLayout = ({
label, label,
noPadding,
actions, actions,
className,
children, children,
leftBar,
rightBar,
className,
noPadding,
leftBarClassName,
rightBarClassName,
topBarClassName,
}: PageLayoutProps) => { }: PageLayoutProps) => {
return ( return (
<ErrorBoundary FallbackComponent={ErrorPage}> <ErrorBoundary FallbackComponent={ErrorPage}>
<div className="relative flex h-full w-full flex-col"> <div className="flex flex-1 bg-background text-foreground overflow-hidden">
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4"> {/* Left Sidebar */}
<button {leftBar && (
type="button" <aside
className="pl-4 transition-all hover:text-accent md:hidden" className={cn(
"p-2 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700",
leftBarClassName
)}
> >
<AlignLeftIcon /> {leftBar}
</button> </aside>
<div className="flex flex-1 items-center justify-between px-4 md:px-0"> )}
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span> {/* Center column (Header + Main) */}
<div className="flex justify-end space-x-4"> <div className="flex flex-1 flex-col min-w-0">
{/* Header */}
<header
className={cn(
"flex h-16 shrink-0 p-2 items-center border-b border-slate-300 dark:border-slate-700 px-4",
topBarClassName
)}
>
{/* Header Content */}
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="text-lg font-medium text-foreground truncate pr-4">
{label}
</span>
<div className="flex items-center space-x-3 md:space-x-4 shrink-0">
{actions?.map((action) => ( {actions?.map((action) => (
<button <button
key={action.icon.displayName} key={action.key}
type="button" type="button"
disabled={action?.disabled} disabled={action.disabled || action.isLoading}
className="transition-all hover:text-accent" className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed"
onClick={action.onClick} onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled}
aria-busy={action.isLoading}
> >
{action?.isLoading ? <Spinner /> : ( {action.isLoading ? (
<Spinner size="sm" />
) : (
<action.icon <action.icon
className={action.iconClasses} className={cn("h-5 w-5", action.iconClasses)}
aria-disabled={action.disabled}
/> />
)} )}
</button> </button>
))} ))}
</div> </div>
</div> </div>
</div> </header>
</div>
<div <main
className={cn( className={cn(
"flex h-full w-full flex-col overflow-y-auto", "flex-1 flex flex-col",
!noPadding && "pl-3 pr-3 ", "overflow-hidden",
!noPadding && "px-2",
className className
)} )}
> >
{children} {children}
<Footer /> </main>
{/* <Footer /> */}
</div> </div>
{/* Right Sidebar */}
{rightBar && (
<aside
className={cn(
"w-[281px] shrink-0 border-l border-slate-300 dark:border-slate-700 p-2",
rightBarClassName
)}
>
{rightBar}
</aside>
)}
</div> </div>
</ErrorBoundary> </ErrorBoundary>
); );

286
src/components/Sidebar.tsx

@ -1,125 +1,131 @@
import React from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts"; import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx"; import { Spinner } from "@components/UI/Spinner.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { import {
BatteryMediumIcon, BatteryMediumIcon,
CircleChevronLeft,
CpuIcon, CpuIcon,
EditIcon,
LayersIcon, LayersIcon,
type LucideIcon, type LucideIcon,
MapIcon, MapIcon,
MessageSquareIcon, MessageSquareIcon,
PenLine,
SearchIcon,
SettingsIcon, SettingsIcon,
SidebarCloseIcon,
SidebarOpenIcon,
UsersIcon, UsersIcon,
ZapIcon, ZapIcon,
} from "lucide-react"; } 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 { export interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
export const Sidebar = ({ children }: SidebarProps) => { interface NavLink {
const { hardware, nodes, metadata } = useDevice();
const myNode = nodes.get(hardware.myNodeNum);
const myMetadata = metadata.get(0);
const { activePage, setActivePage, setDialogOpen } = useDevice();
const [showSidebar, setShowSidebar] = useState<boolean>(true);
interface NavLink {
name: string; name: string;
icon: LucideIcon; icon: LucideIcon;
page: Page; page: Page;
} }
const CollapseToggleButton = () => {
const { isCollapsed, toggleSidebar } = useSidebar();
const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar";
return (
<button
type="button"
aria-label={buttonLabel}
onClick={toggleSidebar}
className={cn(
'absolute top-21 -right-2 z-10 p-0.5 rounded-full transform translate-x-1/2',
'bg-background-primary border border-slate-300 dark:border-slate-700',
'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-100',
'focus:outline-none focus:ring-2 focus:ring-accent transition-transform'
)}
>
<CircleChevronLeft
size={24}
className={cn(
'transition-transform duration-300 ease-in-out',
isCollapsed && 'rotate-180'
)}
/>
</button>
);
}
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, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice();
const { setCommandPaletteOpen } = useAppStore();
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
const myMetadata = metadata.get(0);
const pages: NavLink[] = [ 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", name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`,
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)})`,
icon: UsersIcon, icon: UsersIcon,
page: "nodes", page: "nodes",
}, },
]; ];
return showSidebar return (
? ( <div
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-400"> className={cn(
{myNode === undefined ? ( 'relative bg-background-primary border-slate-300 dark:border-slate-700',
<div className="flex flex-col items-center justify-center px-8 py-6"> 'transition-all duration-300 ease-in-out flex-shrink-0',
<Spinner /> isCollapsed ? 'w-24' : 'w-46 lg:w-64'
<Subtle className="mt-2">Loading device info...</Subtle> )}
</div>
) : (
<>
<div className="flex justify-between px-8 pt-6">
<div>
<span className="text-lg font-medium">
{myNode.user?.shortName ?? "UNK"}
</span>
<Subtle>{myNode.user?.longName ?? "UNK"}</Subtle>
</div>
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
> >
<EditIcon size={16} /> <CollapseToggleButton />
</button>
<button type="button" onClick={() => setShowSidebar(false)}> <div
<SidebarCloseIcon size={24} /> className={cn(
</button> 'h-14 py-4 px-1 flex gap-2 items-center flex-shrink-0 transition-all duration-300 ease-in-out',
</div> 'border-b-[0.5px] border-slate-300 dark:border-slate-700',
<div className="px-8 pb-6"> isCollapsed && 'justify-center px-0'
<div className="flex items-center">
<BatteryMediumIcon size={24} viewBox="0 0 28 24" />
<Subtle>
{myNode.deviceMetrics?.batteryLevel
? myNode.deviceMetrics.batteryLevel > 100
? "Charging"
: `${myNode.deviceMetrics.batteryLevel}%`
: "UNK"}
</Subtle>
</div>
<div className="flex items-center">
<ZapIcon size={24} viewBox="0 0 36 24" />
<Subtle>
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
</Subtle>
</div>
<div className="flex items-center">
<CpuIcon size={24} viewBox="0 0 36 24" />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div>
</div>
</>
)} )}
>
<img
src="Logo.svg"
alt="Meshtastic Logo"
className="size-10 flex-shrink-0 rounded-xl"
/>
<h2
className={cn(
'text-xl font-semibold text-gray-800 dark:text-gray-100 whitespace-nowrap',
'transition-all duration-300 ease-in-out',
isCollapsed
? 'opacity-0 max-w-0 invisible ml-0'
: 'opacity-100 max-w-xs visible ml-2'
)}
>
Meshtastic
</h2>
</div>
<SidebarSection label="Navigation"> <SidebarSection label="Navigation" className="mt-4 px-0">
{pages.map((link) => ( {pages.map((link) => (
<SidebarButton <SidebarButton
key={link.name} key={link.name}
@ -135,14 +141,114 @@ export const Sidebar = ({ children }: SidebarProps) => {
/> />
))} ))}
</SidebarSection> </SidebarSection>
<div className={cn(
'',
'flex-1 min-h-0',
isCollapsed && 'overflow-hidden'
)}
>
{children} {children}
</div> </div>
)
: ( <div className="border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 p-2 flex-shrink-0">
<div className="px-1 pt-8 border-r-[0.5px] border-slate-700"> {myNode === undefined ? (
<button type="button" onClick={() => setShowSidebar(true)}> <div className="flex flex-col items-center justify-center py-6">
<SidebarOpenIcon size={24} /> <Spinner />
<Subtle
className={cn(
'mt-4 transition-opacity duration-300',
isCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
)}
>
Loading...
</Subtle>
</div>
) : (
<>
<div
className={cn(
'flex flex-col items-start justify-between',
isCollapsed && 'items-center justify-center'
)}
>
<div
className={cn(
'flex place-items-center gap-2',
isCollapsed && 'w-full justify-center'
)}
>
<Avatar
text={myNode.user?.shortName ?? myNode.num.toString()}
className="flex-shrink-0"
size="sm"
/>
<p
className={cn(
'max-w-[20ch] whitespace-wrap text-sm font-medium',
'transition-all duration-300 ease-in-out overflow-hidden',
isCollapsed
? 'opacity-0 max-w-0 invisible'
: 'opacity-100 max-w-full visible'
)}
>
{myNode.user?.longName ?? "UNK"}
</p>
</div>
<div
className={cn(
'flex flex-col gap-0.5 ml-2 mt-2',
'transition-all duration-300 ease-in-out',
isCollapsed
? 'opacity-0 max-w-0 h-0 invisible'
: 'opacity-100 max-w-xs h-auto visible'
)}
>
<div className="inline-flex gap-2">
<BatteryMediumIcon size={22} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />
<Subtle>{getBatteryStatus(myNode.deviceMetrics?.batteryLevel)}</Subtle>
</div>
<div className="inline-flex gap-2">
<ZapIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />
<Subtle>{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts</Subtle>
</div>
<div className="inline-flex gap-2">
<CpuIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div>
</div>
</div>
<div
className={cn(
'flex items-center flex-shrink-0 ml-14',
'transition-all duration-300 ease-in-out',
isCollapsed
? 'opacity-0 max-w-0 invisible pointer-events-none'
: 'opacity-100 max-w-xs visible'
)}
>
<button
type="button"
aria-label="Edit device name"
className="p-1 rounded transition-colors hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)}
>
<PenLine size={22} />
</button> </button>
<ThemeSwitcher />
<button
type="button"
className="transition-all hover:text-accent"
onClick={() => setCommandPaletteOpen(true)}
>
<SearchIcon />
</button>
</div>
</>
)}
</div>
</div> </div>
); );
}; };

10
src/components/ThemeSwitcher.tsx

@ -12,9 +12,9 @@ export default function ThemeSwitcher({
const { theme, preference, setPreference } = useTheme(); const { theme, preference, setPreference } = useTheme();
const themeIcons = { const themeIcons = {
light: <Sun className="size-5" />, light: <Sun className="size-6" />,
dark: <Moon className="size-5" />, dark: <Moon className="size-6" />,
system: <Monitor className="size-5" />, system: <Monitor className="size-6" />,
}; };
const toggleTheme = () => { const toggleTheme = () => {
@ -30,7 +30,7 @@ export default function ThemeSwitcher({
<button <button
type="button" type="button"
className={cn( className={cn(
"transition-all duration-300 scale-100 cursor-pointer m-6 p-2 focus:*:data-label:opacity-100", "transition-all duration-300 scale-100 cursor-pointer m-3 p-2 focus:*:data-label:opacity-100",
className, className,
)} )}
onClick={toggleTheme} onClick={toggleTheme}
@ -38,7 +38,7 @@ export default function ThemeSwitcher({
> >
<span <span
data-label data-label
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg" className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-3 opacity-0 rounded-lg"
> >
{firstCharOfPreference.toLocaleUpperCase() + {firstCharOfPreference.toLocaleUpperCase() +
(restOfPreference ?? []).join("")} (restOfPreference ?? []).join("")}

2
src/components/UI/Avatar.tsx

@ -49,7 +49,7 @@ export const Avatar: React.FC<AvatarProps> = ({
className, className,
}) => { }) => {
const sizes = { const sizes = {
sm: "size-11 text-xs", sm: "size-10 text-xs font-light",
lg: "size-16 text-lg", lg: "size-16 text-lg",
}; };

43
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; label: string;
subheader?: string;
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
export const SidebarSection = ({ export const SidebarSection = ({
label: title, label,
children, children,
}: SidebarSectionProps) => ( className,
<div className="px-4 py-2"> }: SidebarSectionProps) => {
<Heading as="h4" className="mb-3 ml-2"> const { isCollapsed } = useSidebar();
{title} return (
<div className={cn(
"py-2",
isCollapsed ? 'px-0' : 'px-3',
className,
)}>
<Heading as="h3" className={cn(
'mb-2',
'uppercase tracking-wider text-md',
'transition-all duration-300 ease-in-out',
'whitespace-nowrap overflow-hidden',
isCollapsed
? 'opacity-0 max-w-0 h-0 invisible px-0 mb-0'
: 'opacity-100 max-w-xs h-auto visible px-1 mb-1'
)}>
{label}
</Heading> </Heading>
<div className="space-y-1">{children}</div>
<div className="space-y-1 h-full">
{children}
</div>
</div> </div>
); );
};

54
src/components/UI/Sidebar/sidebarButton.tsx

@ -1,5 +1,8 @@
import React from "react";
import { Button } from "@components/UI/Button.tsx"; import { Button } from "@components/UI/Button.tsx";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
export interface SidebarButtonProps { export interface SidebarButtonProps {
label: string; label: string;
@ -19,17 +22,54 @@ export const SidebarButton = ({
children, children,
onClick, onClick,
disabled = false, disabled = false,
}: SidebarButtonProps) => ( }: SidebarButtonProps) => {
const { isCollapsed } = useSidebar();
return (
<Button <Button
onClick={onClick} onClick={onClick}
variant={active ? "subtle" : "ghost"} variant={active ? "subtle" : "ghost"}
size="sm" size="sm"
className="flex gap-2 w-full" className={cn(
"flex w-full items-center",
isCollapsed
? 'justify-center gap-0 px-2 h-9'
: 'justify-start gap-2 min-h-9'
)}
disabled={disabled} disabled={disabled}
> >
{Icon && <Icon size={16} />} {Icon && (
{children && children} <Icon
<span className="flex flex-1 justify-start shrink-0">{label}</span> size={isCollapsed ? 20 : 18}
{count && count > 0 && !active && <div className="justify-end text-white rounded-[20%] px-[2%] bg-[rgb(195,0,0)]">{count}</div>} className="flex-shrink-0"
/>
)}
{children}
<span
className={cn(
'flex justify-start text-left',
'transition-all duration-300 ease-in-out',
isCollapsed
? 'opacity-0 max-w-0 invisible flex-1 w-0 whitespace-wrap'
: 'opacity-100 max-w-full visible flex-1 whitespace-normal'
)}
>
{label}
</span>
{!isCollapsed && !active && count && count > 0 && (
<div
className={cn(
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600",
"transition-opacity duration-300 ease-in-out",
isCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
)}
>
{count}
</div>
)}
</Button> </Button>
); );
};

2
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", h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
h2: 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", "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", h4: "scroll-m-20 text-xl font-semibold tracking-tight",
h5: "scroll-m-20 text-lg font-medium tracking-tight", h5: "scroll-m-20 text-lg font-medium tracking-tight",
}; };

40
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;

367
src/core/stores/deviceStore.ts

@ -6,31 +6,13 @@ import { create as createStore } from "zustand";
export type Page = "messages" | "map" | "config" | "channels" | "nodes"; export type Page = "messages" | "map" | "config" | "channels" | "nodes";
export interface MessageWithState extends Types.PacketMetadata<string> {
state: MessageState;
}
export type MessageState = "ack" | "waiting" | 'failed';
export interface ProcessPacketParams { export interface ProcessPacketParams {
from: number; from: number;
snr: number; snr: number;
time: number; time: number;
} }
export type DialogVariant = export type DialogVariant = keyof Device["dialog"];
| "import"
| "QR"
| "shutdown"
| "reboot"
| "rebootOTA"
| "deviceName"
| "nodeRemoval"
| "pkiBackup"
| "nodeDetails"
| "unsafeRoles"
| "refreshKeys"
| "deleteMessages";
type NodeError = { type NodeError = {
node: number; node: number;
@ -46,7 +28,6 @@ export interface Device {
workingConfig: Protobuf.Config.Config[]; workingConfig: Protobuf.Config.Config[];
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[]; workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
hardware: Protobuf.Mesh.MyNodeInfo; hardware: Protobuf.Mesh.MyNodeInfo;
nodes: Map<number, Protobuf.Mesh.NodeInfo>;
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>; metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
traceroutes: Map< traceroutes: Map<
number, number,
@ -57,10 +38,10 @@ export interface Device {
activePage: Page; activePage: Page;
activeNode: number; activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[]; waypoints: Protobuf.Mesh.Waypoint[];
// currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean; pendingSettingsChanges: boolean;
messageDraft: string; messageDraft: string;
unreadCounts: Map<number, number>; unreadCounts: Map<number, number>;
nodesMap: Map<number, Protobuf.Mesh.NodeInfo>;
dialog: { dialog: {
import: boolean; import: boolean;
QR: boolean; QR: boolean;
@ -76,14 +57,12 @@ export interface Device {
deleteMessages: boolean; deleteMessages: boolean;
}; };
setStatus: (status: Types.DeviceStatusEnum) => void; setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void; setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void; setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void; setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
// setMetrics: (metrics: Types.PacketMetadata<Protobuf.Telemetry>) => void;
setActivePage: (page: Page) => void; setActivePage: (page: Page) => void;
setActiveNode: (node: number) => void; setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void; setPendingSettingsChanges: (state: boolean) => void;
@ -105,9 +84,15 @@ export interface Device {
setNodeError: (nodeNum: number, error: string) => void; setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void; clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined; getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean hasNodeError: (nodeNum: number) => boolean;
incrementUnread: (nodeNum: number) => void; incrementUnread: (nodeNum: number) => void;
resetUnread: (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 { export interface DeviceState {
@ -125,6 +110,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
remoteDevices: new Map(), remoteDevices: new Map(),
addDevice: (id: number) => { addDevice: (id: number) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
draft.devices.set(id, { draft.devices.set(id, {
@ -136,7 +122,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
workingConfig: [], workingConfig: [],
workingModuleConfig: [], workingModuleConfig: [],
hardware: create(Protobuf.Mesh.MyNodeInfoSchema), hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
nodes: new Map(),
metadata: new Map(), metadata: new Map(),
traceroutes: new Map(), traceroutes: new Map(),
connection: undefined, connection: undefined,
@ -161,8 +146,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
messageDraft: "", messageDraft: "",
nodeErrors: new Map(), nodeErrors: new Map(),
unreadCounts: new Map(), unreadCounts: new Map(),
nodesMap: new Map(),
// --- Standard Setter Methods ---
setStatus: (status: Types.DeviceStatusEnum) => { setStatus: (status: Types.DeviceStatusEnum) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@ -177,40 +163,16 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
switch (config.payloadVariant.case) { switch (config.payloadVariant.case) {
case "device": { case "device": { device.config.device = config.payloadVariant.value; break; }
device.config.device = config.payloadVariant.value; case "position": { device.config.position = config.payloadVariant.value; break; }
break; case "power": { device.config.power = config.payloadVariant.value; break; }
} case "network": { device.config.network = config.payloadVariant.value; break; }
case "position": { case "display": { device.config.display = config.payloadVariant.value; break; }
device.config.position = config.payloadVariant.value; case "lora": { device.config.lora = config.payloadVariant.value; break; }
break; case "bluetooth": { device.config.bluetooth = config.payloadVariant.value; break; }
} case "security": { device.config.security = config.payloadVariant.value; }
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<DeviceState>((set, get) => ({
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
switch (config.payloadVariant.case) { switch (config.payloadVariant.case) {
case "mqtt": { case "mqtt": { device.moduleConfig.mqtt = config.payloadVariant.value; break; }
device.moduleConfig.mqtt = config.payloadVariant.value; case "serial": { device.moduleConfig.serial = config.payloadVariant.value; break; }
break; case "externalNotification": { device.moduleConfig.externalNotification = config.payloadVariant.value; break; }
} case "storeForward": { device.moduleConfig.storeForward = config.payloadVariant.value; break; }
case "serial": { case "rangeTest": { device.moduleConfig.rangeTest = config.payloadVariant.value; break; }
device.moduleConfig.serial = config.payloadVariant.value; case "telemetry": { device.moduleConfig.telemetry = config.payloadVariant.value; break; }
break; case "cannedMessage": { device.moduleConfig.cannedMessage = config.payloadVariant.value; break; }
} case "audio": { device.moduleConfig.audio = config.payloadVariant.value; break; }
case "externalNotification": { case "neighborInfo": { device.moduleConfig.neighborInfo = config.payloadVariant.value; break; }
device.moduleConfig.externalNotification = case "ambientLighting": { device.moduleConfig.ambientLighting = config.payloadVariant.value; break; }
config.payloadVariant.value; case "detectionSensor": { device.moduleConfig.detectionSensor = config.payloadVariant.value; break; }
break; case "paxcounter": { device.moduleConfig.paxcounter = 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<DeviceState>((set, get) => ({
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) return;
return; const index = device.workingConfig.findIndex(
}
const workingConfigIndex = device?.workingConfig.findIndex(
(wc) => wc.payloadVariant.case === config.payloadVariant.case, (wc) => wc.payloadVariant.case === config.payloadVariant.case,
); );
if (workingConfigIndex !== -1) { if (index !== -1) {
device.workingConfig[workingConfigIndex] = config; device.workingConfig[index] = config;
} else { } else {
device?.workingConfig.push(config); device.workingConfig.push(config);
} }
}), }),
); );
}, },
setWorkingModuleConfig: ( setWorkingModuleConfig: (moduleConfig: Protobuf.ModuleConfig.ModuleConfig) => {
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) return;
return; const index = device.workingModuleConfig.findIndex(
} (wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
const workingModuleConfigIndex = device?.workingModuleConfig
.findIndex(
(wmc) =>
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
); );
if (workingModuleConfigIndex !== -1) { if (index !== -1) {
device.workingModuleConfig[workingModuleConfigIndex] = device.workingModuleConfig[index] = moduleConfig;
moduleConfig;
} else { } else {
device?.workingModuleConfig.push(moduleConfig); device.workingModuleConfig.push(moduleConfig);
} }
}), }),
); );
@ -337,50 +243,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}), }),
); );
}, },
// setMetrics: (metrics: Types.PacketMetadata<Protobuf.Telemetry>) => {
// set(
// produce<DeviceState>((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) => { setActivePage: (page) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@ -405,10 +267,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (device) {
return;
}
device.channels.set(channel.index, channel); device.channels.set(channel.index, channel);
}
}), }),
); );
}, },
@ -417,12 +278,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (device) { if (device) {
const waypointIndex = device.waypoints.findIndex( const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id);
(wp) => wp.id === waypoint.id, if (index !== -1) {
); device.waypoints[index] = waypoint;
if (waypointIndex !== -1) {
device.waypoints[waypointIndex] = waypoint;
} else { } else {
device.waypoints.push(waypoint); device.waypoints.push(waypoint);
} }
@ -434,10 +292,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) {
return; if (!device) return;
} device.nodesMap.set(nodeInfo.num, nodeInfo);
device.nodes.set(nodeInfo.num, nodeInfo);
}), }),
); );
}, },
@ -458,11 +315,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) { if (!device) {
return; return;
} }
const currentNode = device.nodes.get(user.from) ?? const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.user = user.data; currentNode.user = user.data;
device.nodes.set(user.from, currentNode); currentNode.num = user.from;
}), device.nodesMap.set(user.from, currentNode);
})
); );
}, },
addPosition: (position) => { addPosition: (position) => {
@ -472,11 +329,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) { if (!device) {
return; return;
} }
const currentNode = device.nodes.get(position.from) ?? const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.position = position.data; currentNode.position = position.data;
device.nodes.set(position.from, currentNode); currentNode.num = position.from;
}), device.nodesMap.set(position.from, currentNode);
})
); );
}, },
addConnection: (connection) => { addConnection: (connection) => {
@ -493,10 +350,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (device) {
return;
}
device.metadata.set(from, metadata); device.metadata.set(from, metadata);
}
}), }),
); );
}, },
@ -504,17 +360,10 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) return;
return; const routes = device.traceroutes.get(traceroute.from) ?? [];
} routes.push(traceroute);
device.traceroutes.set(traceroute.from, routes);
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]);
}
}), }),
); );
}, },
@ -523,46 +372,38 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
return; return
} }
device.nodes.delete(nodeNum); device.nodesMap.delete(nodeNum);
}), }))
);
}, },
setDialogOpen: (dialog: DialogVariant, open: boolean) => { setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (device) {
return;
}
device.dialog[dialog] = open; device.dialog[dialog] = open;
}
}), }),
); );
}, },
getDialogOpen: (dialog: DialogVariant) => { getDialogOpen: (dialog: DialogVariant) => {
const device = get().devices.get(id); const device = get().devices.get(id);
if (!device) { if (!device) throw new Error(`Device ${id} not found`);
throw new Error("Device not found");
}
return device.dialog[dialog]; return device.dialog[dialog];
}, },
processPacket(data: ProcessPacketParams) { processPacket(data: ProcessPacketParams) {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) return;
return; const node = device.nodesMap.get(data.from);
}
const node = device.nodes.get(data.from);
if (node) { if (node) {
device.nodes.set(data.from, { node.lastHeard = data.time;
...node, node.snr = data.snr;
lastHeard: data.time, device.nodesMap.set(data.from, node);
snr: data.snr,
});
} else { } else {
device.nodes.set( device.nodesMap.set(
data.from, data.from,
create(Protobuf.Mesh.NodeInfoSchema, { create(Protobuf.Mesh.NodeInfoSchema, {
num: data.from, num: data.from,
@ -606,26 +447,19 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}, },
getNodeError: (nodeNum: number) => { getNodeError: (nodeNum: number) => {
const device = get().devices.get(id); const device = get().devices.get(id);
if (!device) { if (!device) throw new Error(`Device ${id} not found`);
throw new Error("Device not found");
}
return device.nodeErrors.get(nodeNum); return device.nodeErrors.get(nodeNum);
}, },
hasNodeError: (nodeNum: number) => { hasNodeError: (nodeNum: number) => {
const device = get().devices.get(id); const device = get().devices.get(id);
if (!device) { if (!device) throw new Error(`Device ${id} not found`);
throw new Error("Device not found");
}
return device.nodeErrors.has(nodeNum); return device.nodeErrors.has(nodeNum);
}, },
incrementUnread: (nodeNum: number) => { incrementUnread: (nodeNum: number) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) return;
console.warn(`incrementUnread: Device with ID ${id} not found.`);
return;
}
const currentCount = device.unreadCounts.get(nodeNum) ?? 0; const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
device.unreadCounts.set(nodeNum, currentCount + 1); device.unreadCounts.set(nodeNum, currentCount + 1);
}) })
@ -635,29 +469,54 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) return;
console.warn(`resetUnread: Device with ID ${id} not found.`);
return;
}
device.unreadCounts.set(nodeNum, 0); device.unreadCounts.set(nodeNum, 0);
if (device.unreadCounts.get(nodeNum) === 0) { if (device.unreadCounts.get(nodeNum) === 0) {
device.unreadCounts.delete(nodeNum); 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); const device = get().devices.get(id);
if (!device) { if (!device) {
throw new Error("Device not found"); throw new Error(`Failed to create or retrieve device with ID ${id}`);
} }
return device; return device;
}, },
removeDevice: (id) => { removeDevice: (id) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {

37
src/core/stores/sidebarStore.tsx

@ -0,0 +1,37 @@
import React, { createContext, useState, useContext, useMemo } from 'react';
interface SidebarContextProps {
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
toggleSidebar: () => void;
}
const SidebarContext = createContext<SidebarContextProps | undefined>(undefined);
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
const toggleSidebar = useMemo(() => () => {
setIsCollapsed(prev => !prev);
}, []);
const value = useMemo(() => ({
isCollapsed,
setIsCollapsed,
toggleSidebar,
}), [isCollapsed, toggleSidebar]);
return (
<SidebarContext.Provider value={value} >
{children}
</SidebarContext.Provider>
);
};
export const useSidebar = (): SidebarContextProps => {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
};

16
src/core/subscriptions.ts

@ -2,6 +2,7 @@ import type { Device } from "@core/stores/deviceStore.ts";
import { MeshDevice, Protobuf } from "@meshtastic/core"; import { MeshDevice, Protobuf } from "@meshtastic/core";
import { MessageType, type MessageStore } from "@core/stores/messageStore.ts"; import { MessageType, type MessageStore } from "@core/stores/messageStore.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts"; import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
export const subscribeAll = ( export const subscribeAll = (
device: Device, device: Device,
@ -10,9 +11,6 @@ export const subscribeAll = (
) => { ) => {
let myNodeNum = 0; let myNodeNum = 0;
// onLogEvent
// onMeshHeartbeat
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => { connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
device.addMetadata(metadataPacket.from, metadataPacket.data); device.addMetadata(metadataPacket.from, metadataPacket.data);
}); });
@ -67,10 +65,8 @@ export const subscribeAll = (
}); });
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
// toast(`New Node Discovered: ${nodeInfo.user?.shortName ?? "UNK"}`, { const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
// icon: "🔎" device.addNodeInfo(nodeWithUser);
// });
device.addNodeInfo(nodeInfo);
}); });
connection.events.onChannelPacket.subscribe((channel) => { connection.events.onChannelPacket.subscribe((channel) => {
@ -126,12 +122,14 @@ export const subscribeAll = (
case Protobuf.Mesh.Routing_Error.NO_CHANNEL: case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`); console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.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; break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`); console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.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; break;
default: { default: {
break; break;

40
src/core/utils/string.ts

@ -29,3 +29,43 @@ export function formatQuantity(
return `${numberFormat.format(value)} ${word}`; 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
}
}

1
src/index.css

@ -25,6 +25,7 @@
--brightness-hover: var(--brightnessHover); --brightness-hover: var(--brightnessHover);
--brightness-press: var(--brightnessPress); --brightness-press: var(--brightnessPress);
--brightness-disabled: var(--brightnessDisabled); --brightness-disabled: var(--brightnessDisabled);
--sidebar-width: @apply w-50 lg:w-64;
} }
[data-theme="light"] { [data-theme="light"] {

7
src/pages/Channels.tsx

@ -31,19 +31,20 @@ const ChannelsPage = () => {
return ( return (
<> <>
<Sidebar />
<PageLayout <PageLayout
label={`Channel: ${ leftBar={<Sidebar />}
currentChannel ? getChannelName(currentChannel) : "Loading..." label={`Channel: ${currentChannel ? getChannelName(currentChannel) : "Loading..."
}`} }`}
actions={[ actions={[
{ {
key: "search",
icon: ImportIcon, icon: ImportIcon,
onClick() { onClick() {
setDialogOpen("import", true); setDialogOpen("import", true);
}, },
}, },
{ {
key: "import",
icon: QrCodeIcon, icon: QrCodeIcon,
onClick() { onClick() {
setDialogOpen("QR", true); setDialogOpen("QR", true);

15
src/pages/Config/index.tsx

@ -8,7 +8,7 @@ import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx"; import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx"; import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react"; import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
import { useState } from "react"; import { useMemo, useState } from "react";
const ConfigPage = () => { const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice(); const { workingConfig, workingModuleConfig, connection } = useDevice();
@ -51,11 +51,14 @@ const ConfigPage = () => {
description: description:
`The configuration change ${moduleConfig.payloadVariant.case} has been saved.`, `The configuration change ${moduleConfig.payloadVariant.case} has been saved.`,
}) })
) )
), ),
); );
setIsSaving(false);
} }
await connection?.commitEditSettings(); await connection?.commitEditSettings();
} catch (_error) { } catch (_error) {
toast({ toast({
title: "Error Saving Config", title: "Error Saving Config",
@ -66,8 +69,8 @@ const ConfigPage = () => {
} }
}; };
return ( const leftSidebar = useMemo(
<> () => (
<Sidebar> <Sidebar>
<SidebarSection label="Config Sections"> <SidebarSection label="Config Sections">
<SidebarButton <SidebarButton
@ -84,7 +87,13 @@ const ConfigPage = () => {
/> />
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
), [])
return (
<>
<PageLayout <PageLayout
leftBar={leftSidebar}
label={activeConfigSection === "device" label={activeConfigSection === "device"
? "Radio Config" ? "Radio Config"
: "Module Config"} : "Module Config"}

9
src/pages/Map/index.tsx

@ -35,7 +35,7 @@ const convertToLatLng = (position: {
}); });
const MapPage = () => { const MapPage = () => {
const { nodes, waypoints } = useDevice(); const { getNodes, waypoints } = useDevice();
const { theme } = useTheme(); const { theme } = useTheme();
const { default: map } = useMap(); const { default: map } = useMap();
@ -48,11 +48,11 @@ const MapPage = () => {
// Filter out nodes without a valid position // Filter out nodes without a valid position
const validNodes = useMemo( const validNodes = useMemo(
() => () =>
Array.from(nodes.values()).filter( getNodes(
(node): node is Protobuf.Mesh.NodeInfo => (node): node is Protobuf.Mesh.NodeInfo =>
Boolean(node.position?.latitudeI), Boolean(node.position?.latitudeI),
), ),
[nodes], [getNodes],
); );
const { const {
@ -149,8 +149,7 @@ const MapPage = () => {
return ( return (
<> <>
<Sidebar /> <PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<PageLayout label="Map" noPadding actions={[]}>
<MapGl <MapGl
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json" mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
attributionControl={false} attributionControl={false}

138
src/pages/Messages.tsx

@ -7,33 +7,38 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, Types } from "@meshtastic/core"; import { Protobuf, Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { getChannelName } from "@pages/Channels.tsx"; import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; import { HashIcon, LockIcon, LockOpenIcon, SearchIcon } from "lucide-react";
import { useState } from "react"; import { useMemo, useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Input } from "@components/UI/Input.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
export const MessagesPage = () => { export const MessagesPage = () => {
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 { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
const { toast } = useToast(); const { toast } = useToast();
const { isCollapsed } = useSidebar()
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const filteredNodes: NodeInfoWithUnread[] = Array.from(nodes.values()) const filteredNodes: NodeInfoWithUnread[] = useMemo(() => {
.filter((node) => node.num !== hardware.myNodeNum) 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)
})
.map((node) => ({ .map((node) => ({
...node, ...node,
unreadCount: unreadCounts.get(node.num) ?? 0, unreadCount: unreadCounts.get(node.num) ?? 0,
})) }))
.filter((node) => {
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
})
.sort((a, b) => b.unreadCount - a.unreadCount); .sort((a, b) => b.unreadCount - a.unreadCount);
}, [getNodes, searchTerm, unreadCounts]);
const allChannels = Array.from(channels.values()); const allChannels = Array.from(channels.values());
@ -42,20 +47,43 @@ export const MessagesPage = () => {
); );
const currentChannel = channels.get(activeChat); const currentChannel = channels.get(activeChat);
const otherNode = nodes.get(activeChat); const otherNode = getNode(activeChat);
const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
const isDirect = chatType === MessageType.Direct; const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast; const isBroadcast = chatType === MessageType.Broadcast;
const currentChat = { type: chatType, id: activeChat }; const currentChat = { type: chatType, id: activeChat };
const renderChatContent = () => {
switch (chatType) {
case MessageType.Broadcast:
return ( return (
<> <ChannelChat
messages={getMessages(MessageType.Broadcast, {
myNodeNum: getNodeNum(),
channel: currentChannel?.index
})}
/>
);
case MessageType.Direct:
return (
<ChannelChat
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
/>
);
default:
return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
Select a channel or node to start messaging.
</div>
);
}
}
const leftSidebar = useMemo(() => (
<Sidebar> <Sidebar>
<SidebarSection label="Channels"> <SidebarSection label="Channels" className="py-2 px-0">
{filteredChannels.map((channel) => ( {filteredChannels?.map((channel) => (
<SidebarButton <SidebarButton
key={channel.index} key={channel.index}
count={unreadCounts.get(channel.index)} count={unreadCounts.get(channel.index)}
@ -67,25 +95,31 @@ export const MessagesPage = () => {
resetUnread(channel.index); resetUnread(channel.index);
}} }}
> >
<HashIcon size={16} className="mr-2" /> <HashIcon size={16} className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")} />
</SidebarButton> </SidebarButton>
))} ))}
</SidebarSection> </SidebarSection>
<SidebarSection label="Nodes"> </Sidebar>
<div className="p-1 mb-4"> ), [filteredChannels, unreadCounts, activeChat, chatType, isCollapsed, setActiveChat, setChatType, resetUnread]);
<input
const rightSidebar = useMemo(() => (
<SidebarSection label="" className="px-0 relative">
<label className="">
<Input
type="text" type="text"
placeholder="Search nodes..." placeholder="Search nodes..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => 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" className={cn('relative 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-1 focus:ring-slate-700 focus:dark:ring-slate-100')}
/> />
</div> </label>
<div className="flex flex-col gap-3.5"> <div className={cn(
{filteredNodes.map((node) => ( "flex flex-col gap-3 pt-4",
)}>
{filteredNodes?.map((node) => (
<SidebarButton <SidebarButton
key={node.num} key={node.num}
label={node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`} label={node.user?.longName ?? `UNK`}
count={node.unreadCount > 0 ? node.unreadCount : undefined} count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={activeChat === node.num && chatType === MessageType.Direct} active={activeChat === node.num && chatType === MessageType.Direct}
onClick={() => { onClick={() => {
@ -94,7 +128,7 @@ export const MessagesPage = () => {
resetUnread(node.num); resetUnread(node.num);
}}> }}>
<Avatar <Avatar
text={node.user?.shortName ?? node.num.toString()} text={node.user?.shortName ?? "UNK"}
className={cn(hasNodeError(node.num) && "text-red-500")} className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)} showError={hasNodeError(node.num)}
size="sm" size="sm"
@ -103,19 +137,22 @@ export const MessagesPage = () => {
))} ))}
</div> </div>
</SidebarSection> </SidebarSection>
</Sidebar> ), [filteredNodes, searchTerm, activeChat, chatType, setActiveChat, setChatType, resetUnread, hasNodeError]);
<div className="flex flex-col w-full h-full">
return (
<PageLayout <PageLayout
className="flex flex-col h-full"
label={`Messages: ${isBroadcast && currentChannel label={`Messages: ${isBroadcast && currentChannel
? getChannelName(currentChannel) ? getChannelName(currentChannel)
: isDirect && otherNode : isDirect && otherNode
? (otherNode.user?.longName ?? nodeHex) ? (otherNode.user?.longName ?? "Unknown")
: "Select a Chat" : "Select a Chat"
}`} }`}
rightBar={rightSidebar}
leftBar={leftSidebar}
actions={isDirect && otherNode actions={isDirect && otherNode
? [ ? [
{ {
key: 'encryption',
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon, icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
iconClasses: otherNode.user?.publicKey?.length iconClasses: otherNode.user?.publicKey?.length
? "text-green-600" ? "text-green-600"
@ -131,51 +168,22 @@ export const MessagesPage = () => {
] ]
: []} : []}
> >
<div className="flex-1 overflow-y-auto"> <div className="flex flex-1 flex-col overflow-hidden">
{isBroadcast && currentChannel && ( {renderChatContent()}
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<ChannelChat
messages={getMessages(MessageType.Broadcast, {
myNodeNum: getNodeNum(),
channel: currentChannel?.index
})}
/>
</div>
</div>
)}
{isDirect && otherNode && ( <div className="flex-none mt-auto dark:bg-slate-900 p-4">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<ChannelChat
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
/>
</div>
</div>
)}
{!isBroadcast && !isDirect && (
<div className="flex items-center justify-center h-full text-slate-500">
Select a channel or node to start messaging.
</div>
)}
</div>
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
{(isBroadcast || isDirect) ? ( {(isBroadcast || isDirect) ? (
<MessageInput <MessageInput
to={isDirect ? activeChat : MessageType.Broadcast} to={isDirect ? activeChat : MessageType.Broadcast}
channel={isDirect ? Types.ChannelNumber.Primary : currentChat.id} channel={isBroadcast ? currentChat.id : Types.ChannelNumber.Primary}
maxBytes={200} maxBytes={200}
/> />
) : ( ) : (
<div className="text-center text-slate-400 italic">Select a chat to send a message.</div> <div className="p-4 text-center text-slate-400 italic">Select a chat to send a message.</div>
)} )}
</div> </div>
</PageLayout>
</div> </div>
</> </PageLayout>
); );
}; };

62
src/pages/Nodes.tsx

@ -13,27 +13,29 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react"; import { LockIcon, LockOpenIcon } from "lucide-react";
import { type JSX, useCallback, useEffect, useState } from "react"; import { type JSX, useCallback, useEffect, useState } from "react";
import { base16 } from "rfc4648"; import { base16 } from "rfc4648";
import { Input } from "@components/UI/Input.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
export interface DeleteNoteDialogProps { export interface DeleteNoteDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
function shortNameFromNode( // function shortNameFromNode(
node: ReturnType<useDevice>["nodes"][number], // node: ReturnType<useDevice>["nodes"][number],
): string { // ): string {
const shortNameOfNode = node.user?.shortName ?? // const shortNameOfNode = node.user?.shortName ??
(node.user?.macaddr // (node.user?.macaddr
? `${base16 // ? `${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? []) // .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase() // .toLowerCase()
}` // }`
: `${numberToHexUnpadded(node.num).slice(-4)}`); // : `${numberToHexUnpadded(node.num).slice(-4)}`);
return String(shortNameOfNode); // return String(shortNameOfNode);
} // }
const NodesPage = (): JSX.Element => { const NodesPage = (): JSX.Element => {
const { nodes, hardware, connection } = useDevice(); const { getNodes, hardware, connection } = useDevice();
const [selectedNode, setSelectedNode] = useState< const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined Protobuf.Mesh.NodeInfo | undefined
>(undefined); >(undefined);
@ -45,11 +47,7 @@ const NodesPage = (): JSX.Element => {
>(); >();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const filteredNodes = Array.from(nodes.values()).filter((node) => { const filteredNodes = getNodes()
if (node.num === hardware.myNodeNum) return false;
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
return nodeName.toLowerCase().includes(searchTerm.toLowerCase());
});
useEffect(() => { useEffect(() => {
if (!connection) return; if (!connection) return;
@ -84,18 +82,16 @@ const NodesPage = (): JSX.Element => {
return ( return (
<> <>
<Sidebar /> <PageLayout label="" leftBar={<Sidebar />} className="flex flex-col w-full">
<div className="flex flex-col w-full"> <div className="p-2">
<div className="p-4"> <Input
<input
type="text"
placeholder="Search nodes..." placeholder="Search nodes..."
value={searchTerm} value={searchTerm}
className="bg-transparent"
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => 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"
/> />
</div> </div>
<div className="overflow-y-auto h-full"> <div className="overflow-y-auto">
<Table <Table
headings={[ headings={[
{ title: "", type: "blank", sortable: false }, { title: "", type: "blank", sortable: false },
@ -109,7 +105,7 @@ const NodesPage = (): JSX.Element => {
]} ]}
rows={filteredNodes.map((node) => [ rows={filteredNodes.map((node) => [
<div key={node.num}> <div key={node.num}>
<Avatar text={shortNameFromNode(node)} /> <Avatar text={node.user?.shortName ?? "UNK "} />
</div>, </div>,
<h1 <h1
key="longName" key="longName"
@ -117,23 +113,17 @@ const NodesPage = (): JSX.Element => {
onKeyUp={(evt) => { onKeyUp={(evt) => {
evt.key === "Enter" && setSelectedNode(node); evt.key === "Enter" && setSelectedNode(node);
}} }}
className="cursor-pointer underline" className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
{node.user?.longName ?? {node.user?.longName ?? numberToHexUnpadded(node.num)}
(node.user?.macaddr
? `Meshtastic ${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()
}`
: `!${numberToHexUnpadded(node.num)}`)}
</h1>, </h1>,
<Mono key="hops"> <Mono key="hops">
{node.lastHeard !== 0 {node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0 ? node.viaMqtt === false && node.hopsAway === 0
? "Direct" ? "Direct"
: `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop" : `${node.hopsAway?.toString()} ${node.hopsAway ?? 0 > 1 ? "hops" : "hop"
} away` } away`
: "-"} : "-"}
{node.viaMqtt === true ? ", via MQTT" : ""} {node.viaMqtt === true ? ", via MQTT" : ""}
@ -181,7 +171,7 @@ const NodesPage = (): JSX.Element => {
/> />
</div> </div>
<Footer /> <Footer />
</div> </PageLayout>
</> </>
); );
}; };

Loading…
Cancel
Save