Add monorepo support to project (#682)

* feat: add monorepo support

* fix: correct relatedProjects structure in vercel.json

* update github actions with monorepo support

* removed legacy dep

* moved jsr config into web package
This commit is contained in:
Dan Ditomaso
2025-06-25 21:56:55 -04:00
committed by GitHub
parent 85ee0747d1
commit 1c13343e8a
463 changed files with 7568 additions and 2196 deletions
+83
View File
@@ -0,0 +1,83 @@
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Channel } from "@components/PageComponents/Channel.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Types } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/core";
import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
return channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", { ns: "channels", index: channel.index });
};
const ChannelsPage = () => {
const { t } = useTranslation("channels");
const { channels, setDialogOpen } = useDevice();
const [activeChannel] = useState<Types.ChannelNumber>(
Types.ChannelNumber.Primary,
);
const currentChannel = channels.get(activeChannel);
const allChannels = Array.from(channels.values());
return (
<>
<PageLayout
contentClassName="overflow-auto"
leftBar={<Sidebar />}
label={currentChannel
? getChannelName(currentChannel)
: t("loading", { ns: "common" })}
actions={[
{
key: "import",
icon: UploadIcon,
onClick() {
setDialogOpen("import", true);
},
},
{
key: "qr",
icon: QrCodeIcon,
onClick() {
setDialogOpen("QR", true);
},
},
]}
>
<Tabs defaultValue="0">
<TabsList className="dark:bg-slate-800">
{allChannels.map((channel) => (
<TabsTrigger
key={channel.index}
value={channel.index.toString()}
className="dark:text-white"
>
{getChannelName(channel)}
</TabsTrigger>
))}
</TabsList>
{allChannels.map((channel) => (
<TabsContent key={channel.index} value={channel.index.toString()}>
<Channel key={channel.index} channel={channel} />
</TabsContent>
))}
</Tabs>
</PageLayout>
</>
);
};
export default ChannelsPage;
@@ -0,0 +1,119 @@
import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
import { Display } from "@components/PageComponents/Config/Display.tsx";
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
import { Network } from "@components/PageComponents/Config/Network/index.tsx";
import { Position } from "@components/PageComponents/Config/Position.tsx";
import { Power } from "@components/PageComponents/Config/Power.tsx";
import { Security } from "@components/PageComponents/Config/Security/Security.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { useTranslation } from "react-i18next";
import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts";
import { useMemo } from "react";
import { type ComponentType, Suspense } from "react";
import type { UseFormReturn } from "react-hook-form";
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
interface ConfigProps {
// We can get rid of this exception if we import every config schema and pass the union type
// deno-lint-ignore no-explicit-any
onFormInit: (methods: UseFormReturn<any>) => void;
}
type TabItem = {
case: ValidConfigType;
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
const { getWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig");
const tabs: TabItem[] = [
{
case: "device",
label: t("page.tabDevice"),
element: Device,
count: 0,
},
{
case: "position",
label: t("page.tabPosition"),
element: Position,
},
{
case: "power",
label: t("page.tabPower"),
element: Power,
},
{
case: "network",
label: t("page.tabNetwork"),
element: Network,
},
{
case: "display",
label: t("page.tabDisplay"),
element: Display,
},
{
case: "lora",
label: t("page.tabLora"),
element: LoRa,
},
{
case: "bluetooth",
label: t("page.tabBluetooth"),
element: Bluetooth,
},
{
case: "security",
label: t("page.tabSecurity"),
element: Security,
},
] as const;
const flags = useMemo(
() => new Map(tabs.map((tab) => [tab.case, getWorkingConfig(tab.case)])),
[tabs, getWorkingConfig],
);
return (
<Tabs defaultValue={t("page.tabDevice")}>
<TabsList className="dark:bg-slate-700">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
</span>
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
</span>
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<ConfigSuspender configCase={tab.case}>
<tab.element onFormInit={onFormInit} />
</ConfigSuspender>
</Suspense>
</TabsContent>
))}
</Tabs>
);
};
@@ -0,0 +1,142 @@
import { AmbientLighting } from "@components/PageComponents/ModuleConfig/AmbientLighting.tsx";
import { DetectionSensor } from "@components/PageComponents/ModuleConfig/DetectionSensor.tsx";
import { NeighborInfo } from "@components/PageComponents/ModuleConfig/NeighborInfo.tsx";
import { Audio } from "@components/PageComponents/ModuleConfig/Audio.tsx";
import { CannedMessage } from "@components/PageComponents/ModuleConfig/CannedMessage.tsx";
import { ExternalNotification } from "@components/PageComponents/ModuleConfig/ExternalNotification.tsx";
import { MQTT } from "@components/PageComponents/ModuleConfig/MQTT.tsx";
import { Paxcounter } from "@components/PageComponents/ModuleConfig/Paxcounter.tsx";
import { RangeTest } from "@components/PageComponents/ModuleConfig/RangeTest.tsx";
import { Serial } from "@components/PageComponents/ModuleConfig/Serial.tsx";
import { StoreForward } from "@components/PageComponents/ModuleConfig/StoreForward.tsx";
import { Telemetry } from "@components/PageComponents/ModuleConfig/Telemetry.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { useTranslation } from "react-i18next";
import {
useDevice,
type ValidModuleConfigType,
} from "@core/stores/deviceStore.ts";
import { useMemo } from "react";
import { type ComponentType, Suspense } from "react";
import type { UseFormReturn } from "react-hook-form";
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
interface ConfigProps {
// We can get rid of this exception if we import every config schema and pass the union type
// deno-lint-ignore no-explicit-any
onFormInit: (methods: UseFormReturn<any>) => void;
}
type TabItem = {
case: ValidModuleConfigType;
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const { getWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const tabs: TabItem[] = [
{
case: "mqtt",
label: t("page.tabMqtt"),
element: MQTT,
},
{
case: "serial",
label: t("page.tabSerial"),
element: Serial,
},
{
case: "externalNotification",
label: t("page.tabExternalNotification"),
element: ExternalNotification,
},
{
case: "storeForward",
label: t("page.tabStoreAndForward"),
element: StoreForward,
},
{
case: "rangeTest",
label: t("page.tabRangeTest"),
element: RangeTest,
},
{
case: "telemetry",
label: t("page.tabTelemetry"),
element: Telemetry,
},
{
case: "cannedMessage",
label: t("page.tabCannedMessage"),
element: CannedMessage,
},
{
case: "audio",
label: t("page.tabAudio"),
element: Audio,
},
{
case: "neighborInfo",
label: t("page.tabNeighborInfo"),
element: NeighborInfo,
},
{
case: "ambientLighting",
label: t("page.tabAmbientLighting"),
element: AmbientLighting,
},
{
case: "detectionSensor",
label: t("page.tabDetectionSensor"),
element: DetectionSensor,
},
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
] as const;
const flags = useMemo(
() =>
new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])),
[tabs, getWorkingModuleConfig],
);
return (
<Tabs defaultValue={t("page.tabMqtt")}>
<TabsList className="dark:bg-slate-800">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
</span>
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
</span>
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<ConfigSuspender moduleConfigCase={tab.case}>
<tab.element onFormInit={onFormInit} />
</ConfigSuspender>
</Suspense>
</TabsContent>
))}
</Tabs>
);
};
+237
View File
@@ -0,0 +1,237 @@
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import {
BoxesIcon,
RefreshCwIcon,
SaveIcon,
SaveOff,
SettingsIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@core/utils/cn.ts";
import type { UseFormReturn } from "react-hook-form";
const ConfigPage = () => {
const {
workingConfig,
workingModuleConfig,
connection,
removeWorkingConfig,
removeWorkingModuleConfig,
setConfig,
setModuleConfig,
} = useDevice();
const { hasErrors } = useAppStore();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module"
>("device");
const [isSaving, setIsSaving] = useState(false);
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
const { toast } = useToast();
const { t } = useTranslation("deviceConfig");
const onFormInit = (methods: UseFormReturn) => {
setFormMethods(methods);
};
const handleSave = async () => {
if (hasErrors()) {
return toast({
title: t("toast.validationError.title"),
description: t("toast.validationError.description"),
});
}
setIsSaving(true);
try {
await Promise.all(
workingConfig.map((newConfig) =>
connection?.setConfig(newConfig).then(() => {
toast({
title: t("toast.saveSuccess.title"),
description: t("toast.saveSuccess.description", {
case: newConfig.payloadVariant.case,
}),
});
})
),
);
await Promise.all(
workingModuleConfig.map((newModuleConfig) =>
connection?.setModuleConfig(newModuleConfig).then(() =>
toast({
title: t("toast.saveSuccess.title"),
description: t("toast.saveSuccess.description", {
case: newModuleConfig.payloadVariant.case,
}),
})
)
),
);
await connection?.commitEditSettings().then(() => {
if (formMethods) {
formMethods.reset({}, {
keepValues: true,
});
}
workingConfig.map((newConfig) => setConfig(newConfig));
workingModuleConfig.map((newModuleConfig) =>
setModuleConfig(newModuleConfig)
);
removeWorkingConfig();
removeWorkingModuleConfig();
});
} catch (_error) {
toast({
title: t("toast.configSaveError.title"),
description: t("toast.configSaveError.description"),
});
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
if (formMethods) {
formMethods.reset();
}
removeWorkingConfig();
removeWorkingModuleConfig();
};
const leftSidebar = useMemo(
() => (
<Sidebar>
<SidebarSection
label={t("sidebar.label")}
className="py-2 px-0"
>
<SidebarButton
label={t("navigation.radioConfig")}
active={activeConfigSection === "device"}
onClick={() => setActiveConfigSection("device")}
Icon={SettingsIcon}
isDirty={workingConfig.length > 0}
count={workingConfig.length}
/>
<SidebarButton
label={t("navigation.moduleConfig")}
active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")}
Icon={BoxesIcon}
isDirty={workingModuleConfig.length > 0}
count={workingModuleConfig.length}
/>
</SidebarSection>
</Sidebar>
),
[activeConfigSection, workingConfig, workingModuleConfig],
);
const buttonOpacity = useMemo(() => {
const isFormDirty = formMethods?.formState.isDirty ?? false;
const hasDirtyFields =
(Object.keys(formMethods?.formState.dirtyFields ?? {}).length ?? 0) > 0;
const hasWorkingConfig = workingConfig.length > 0;
const hasWorkingModuleConfig = workingModuleConfig.length > 0;
const shouldShowButton = (isFormDirty && hasDirtyFields) ||
hasWorkingConfig || hasWorkingModuleConfig;
return shouldShowButton ? "opacity-100" : "opacity-0";
}, [
formMethods?.formState.isDirty,
formMethods?.formState.dirtyFields,
workingConfig,
workingModuleConfig,
]);
const isValid = useMemo(() => {
return Object.keys(formMethods?.formState.errors ?? {}).length === 0;
}, [formMethods?.formState.errors]);
const actions = useMemo(() => [
{
key: "unsavedChanges",
label: t("common:formValidation.unsavedChanges"),
onClick: () => {},
className: cn([
"bg-blue-500 text-slate-900 hover:bg-initial",
"transition-colors duration-200",
buttonOpacity,
"transition-opacity",
]),
},
{
key: "reset",
icon: RefreshCwIcon,
label: t("common:button.reset"),
onClick: handleReset,
className: cn([
buttonOpacity,
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
]),
},
{
key: "save",
icon: !isValid ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: isSaving ||
!isValid ||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
iconClasses: !isValid
? "text-red-400 cursor-not-allowed"
: "cursor-pointer",
className: cn([
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black",
"disabled:hover:cursor-not-allowed cursor-pointer",
]),
onClick: handleSave,
label: t("common:button.save"),
},
], [
activeConfigSection,
isSaving,
isValid,
buttonOpacity,
workingConfig,
workingModuleConfig,
]);
return (
<>
<PageLayout
contentClassName="overflow-auto"
leftBar={leftSidebar}
label={activeConfigSection === "device"
? t("navigation.radioConfig")
: t("navigation.moduleConfig")}
actions={actions}
>
{activeConfigSection === "device"
? <DeviceConfig onFormInit={onFormInit} />
: <ModuleConfig onFormInit={onFormInit} />}
</PageLayout>
</>
);
};
export default ConfigPage;
+103
View File
@@ -0,0 +1,103 @@
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
export const Dashboard = () => {
const { t } = useTranslation("dashboard");
const { setConnectDialogOpen, setSelectedDevice } = useAppStore();
const { getDevices } = useDeviceStore();
const devices = useMemo(() => getDevices(), [getDevices]);
return (
<>
<div className="flex flex-col gap-3 p-3 px-8">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Heading as="h3">
{t("dashboard.title")}
</Heading>
<Subtle>
{t("dashboard.description")}
</Subtle>
</div>
<LanguageSwitcher />
</div>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length
? (
<ul className="grow divide-y divide-slate-200">
{devices.map((device) => {
return (
<li key={device.id}>
<button
type="button"
className="w-full px-4 py-4 sm:px-6"
onClick={() => {
setSelectedDevice(device.id);
}}
>
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.getNode(device.hardware.myNodeNum)?.user
?.longName ??
t("unknown.shortName")}
</p>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-slate-500">
<UsersIcon
size={20}
className="text-slate-400"
aria-hidden="true"
/>
{device.getNodesLength() === 0
? 0
: device.getNodesLength() - 1}
</div>
</div>
</div>
</button>
</li>
);
})}
</ul>
)
: (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon
size={48}
className="mx-auto text-text-secondary"
/>
<Heading as="h3">
{t("dashboard.noDevicesTitle")}
</Heading>
<Subtle>
{t("dashboard.noDevicesDescription")}
</Subtle>
<Button
className="gap-2"
variant="default"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
{t(
"dashboard.button_newConnection",
)}
</Button>
</div>
)}
</div>
</div>
</>
);
};
+198
View File
@@ -0,0 +1,198 @@
import { NodeDetail } from "../../components/PageComponents/Map/NodeDetail.tsx";
import { Avatar } from "../../components/UI/Avatar.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core";
import { bbox, lineString } from "@turf/turf";
import { FunnelIcon, MapPinIcon } from "lucide-react";
import { useCallback, useDeferredValue, useMemo, useState } from "react";
import { Marker, Popup, useMap } from "react-map-gl/maplibre";
import { Map } from "@components/Map.tsx";
import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import { cn } from "@core/utils/cn.ts";
type NodePosition = {
latitude: number;
longitude: number;
};
const convertToLatLng = (position?: {
latitudeI?: number;
longitudeI?: number;
}): NodePosition => ({
latitude: (position?.latitudeI ?? 0) / 1e7,
longitude: (position?.longitudeI ?? 0) / 1e7,
});
const MapPage = () => {
const { getNodes, waypoints, hasNodeError } = useDevice();
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
const { default: map } = useMap();
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | null
>(null);
const validNodes = useMemo(
() =>
getNodes(
(node): node is Protobuf.Mesh.NodeInfo =>
Boolean(node.position?.latitudeI),
),
[getNodes],
);
const [filterState, setFilterState] = useState<FilterState>(() =>
defaultFilterValues
);
const deferredFilterState = useDeferredValue(filterState);
const filteredNodes = useMemo(
() => validNodes.filter((node) => nodeFilter(node, deferredFilterState)),
[validNodes, deferredFilterState],
);
const handleMarkerClick = useCallback(
(node: Protobuf.Mesh.NodeInfo, event: { originalEvent: MouseEvent }) => {
event?.originalEvent?.stopPropagation();
setSelectedNode(node);
if (map) {
const position = convertToLatLng(node.position);
map.easeTo({
center: [position.longitude, position.latitude],
zoom: map?.getZoom(),
});
}
},
[map],
);
// Get the bounds of the map based on the nodes furtherest away from center
const getMapBounds = useCallback(() => {
if (!map || validNodes.length === 0) return;
if (validNodes.length === 1) {
map.easeTo({
zoom: map.getZoom(),
center: [
(validNodes[0].position?.longitudeI ?? 0) / 1e7,
(validNodes[0].position?.latitudeI ?? 0) / 1e7,
],
});
return;
}
const line = lineString(
validNodes.map((n) => [
(n.position?.latitudeI ?? 0) / 1e7,
(n.position?.longitudeI ?? 0) / 1e7,
]),
);
const bounds = bbox(line);
const center = map.cameraForBounds(
[
[bounds[1], bounds[0]],
[bounds[3], bounds[2]],
],
{ padding: { top: 10, bottom: 10, left: 10, right: 10 } },
);
if (center) {
map.easeTo(center);
}
}, [map, validNodes]);
// Generate all markers
const markers = useMemo(
() =>
filteredNodes.map((node) => {
const position = convertToLatLng(node.position);
return (
<Marker
key={`marker-${node.num}`}
longitude={position.longitude}
latitude={position.latitude}
anchor="bottom"
onClick={(e) => handleMarkerClick(node, e)}
>
<Avatar
text={node.user?.shortName?.toString() ?? node.num.toString()}
className="border-[1.5px] border-slate-600 shadow-m shadow-slate-600"
showError={hasNodeError(node.num)}
showFavorite={node.isFavorite}
/>
</Marker>
);
}),
[filteredNodes, handleMarkerClick],
);
return (
<>
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<Map onLoad={getMapBounds}>
{waypoints.map((wp) => (
<Marker
key={wp.id}
longitude={(wp.longitudeI ?? 0) / 1e7}
latitude={(wp.latitudeI ?? 0) / 1e7}
anchor="bottom"
>
<div>
<MapPinIcon size={16} />
</div>
</Marker>
))}
{markers}
{selectedNode && (() => {
const position = convertToLatLng(selectedNode.position);
return (
<Popup
key={selectedNode.num}
anchor="top"
longitude={position.longitude}
latitude={position.latitude}
onClose={() => setSelectedNode(null)}
className="w-full"
>
<NodeDetail node={selectedNode} />
</Popup>
);
})()}
</Map>
<FilterControl
filterState={filterState}
defaultFilterValues={defaultFilterValues}
setFilterState={setFilterState}
isDirty={isFilterDirty(filterState)}
parameters={{
popoverContentProps: {
side: "bottom",
align: "end",
sideOffset: 12,
},
popoverTriggerClassName: cn(
"fixed top-45.5 right-2.5 w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-50 dark:hover:bg-stone-200 dark:active:bg-stone-300",
isFilterDirty(filterState)
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
: "",
),
triggerIcon: <FunnelIcon className="w-5" />,
showTextSearch: true,
}}
/>
</PageLayout>
</>
);
};
export default MapPage;
+363
View File
@@ -0,0 +1,363 @@
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useState,
} from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts";
import {
MessageState,
MessageType,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Input } from "@components/UI/Input.tsx";
import { randId } from "@core/utils/randId.ts";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { messagesWithParamsRoute } from "@app/routes.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
function SelectMessageChat() {
const { t } = useTranslation("messages");
return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
}
export const MessagesPage = () => {
const {
channels,
getNodes,
getNode,
hasNodeError,
getUnreadCount,
resetUnread,
connection,
} = useDevice();
const {
getMyNodeNum,
getMessages,
setMessageState,
} = useMessageStore();
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
const navigate = useNavigate();
const { toast } = useToast();
const { isCollapsed } = useSidebar();
const [searchTerm, setSearchTerm] = useState<string>("");
const { t } = useTranslation(["messages", "channels", "ui"]);
const deferredSearch = useDeferredValue(searchTerm);
const navigateToChat = useCallback((type: MessageType, id: string) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${id}` });
}, [navigate]);
const chatType = type === "direct"
? MessageType.Direct
: MessageType.Broadcast;
const numericChatId = Number(chatId);
const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
useEffect(() => {
if (!type && !chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[0];
navigateToChat(MessageType.Broadcast, defaultChannel.index.toString());
}
}, [type, chatId, filteredChannels, navigateToChat]);
const currentChannel = channels.get(numericChatId);
const otherNode = getNode(numericChatId);
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.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) => ({
...node,
unreadCount: getUnreadCount(node.num) ?? 0,
}))
.sort((a, b) => {
const diff = b.unreadCount - a.unreadCount;
if (diff !== 0) return diff;
return Number(b.isFavorite) - Number(a.isFavorite);
});
};
const sendText = useCallback(async (message: string) => {
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
let messageId: number | undefined;
try {
messageId = await connection?.sendText(
message,
toValue,
true,
channelValue,
);
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId,
newState: MessageState.Ack,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: numericChatId,
messageId,
newState: MessageState.Ack,
});
}
} else {
console.warn("sendText completed but messageId is undefined");
}
} catch (e: unknown) {
console.error("Failed to send message:", e);
const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId: failedId,
newState: MessageState.Failed,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: numericChatId,
messageId: failedId,
newState: MessageState.Failed,
});
}
}
}, [
numericChatId,
chatId,
chatType,
connection,
getMyNodeNum,
setMessageState,
]);
const renderChatContent = () => {
switch (chatType) {
case MessageType.Broadcast:
return (
<ChannelChat
messages={getMessages({
type: MessageType.Broadcast,
channelId: numericChatId,
}).reverse()}
/>
);
case MessageType.Direct:
return (
<ChannelChat
messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: numericChatId,
}).reverse()}
/>
);
default:
return <SelectMessageChat />;
}
};
const leftSidebar = useMemo(() => (
<Sidebar>
<SidebarSection
label={t("navigation.channels")}
className="py-2 px-0"
>
{filteredChannels?.map((channel) => (
<SidebarButton
key={channel.index}
count={getUnreadCount(channel.index)}
label={channel.settings?.name ||
(channel.index === 0
? t("page.broadcastLabel", { ns: "channels" })
: t("page.channelLabel", {
index: channel.index,
ns: "channels",
}))}
active={numericChatId === channel.index &&
chatType === MessageType.Broadcast}
onClick={() => {
navigateToChat(MessageType.Broadcast, channel.index.toString());
resetUnread(channel.index);
}}
>
<HashIcon
size={16}
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
/>
</SidebarButton>
))}
</SidebarSection>
</Sidebar>
), [
filteredChannels,
numericChatId,
chatType,
isCollapsed,
getUnreadCount,
navigateToChat,
resetUnread,
t,
]);
const rightSidebar = useMemo(
() => (
<SidebarSection
label=""
className="px-0 flex flex-col h-full overflow-y-auto"
>
<label className="p-2 block">
<Input
type="text"
placeholder={t("search.nodes")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
showClearButton={!!searchTerm}
/>
</label>
<div
className={cn(
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ",
)}
>
{filteredNodes()?.map((node) => (
<SidebarButton
key={node.num}
preventCollapse
label={node.user?.longName ??
t("unknown.shortName")}
count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={numericChatId === node.num &&
chatType === MessageType.Direct}
onClick={() => {
navigateToChat(MessageType.Direct, node.num.toString());
resetUnread(node.num);
}}
>
<Avatar
text={node.user?.shortName ??
t("unknown.shortName")}
className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
showFavorite={node.isFavorite}
size="sm"
/>
</SidebarButton>
))}
</div>
</SidebarSection>
),
[
filteredNodes,
searchTerm,
numericChatId,
chatType,
navigateToChat,
resetUnread,
hasNodeError,
t,
],
);
return (
<PageLayout
label={`${
t("page.title", {
chatName: isBroadcast && currentChannel
? getChannelName(currentChannel)
: isDirect && otherNode
? (otherNode.user?.longName ?? t("unknown.longName"))
: t("emptyState.title"),
})
}
`}
rightBar={rightSidebar}
leftBar={leftSidebar}
actions={isDirect && otherNode
? [
{
key: "encryption",
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
iconClasses: otherNode.user?.publicKey?.length
? "text-green-600"
: "text-yellow-300",
onClick() {
toast({
title: otherNode.user?.publicKey?.length
? t("toast.messages.pkiEncryption.title")
: t("toast.messages.pskEncryption.title"),
});
},
},
]
: []}
>
<div className="flex flex-1 flex-col overflow-hidden">
{renderChatContent()}
<div className="flex-none dark:bg-slate-900 p-2">
{(isBroadcast || isDirect)
? (
<MessageInput
to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText}
maxBytes={200}
/>
)
: (
<div className="p-4 text-center text-slate-400 italic">
{t("sendMessage.sendButton", { ns: "messages" })}
</div>
)}
</div>
</div>
</PageLayout>
);
};
export default MessagesPage;
+283
View File
@@ -0,0 +1,283 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import {
type DataRow,
type Heading,
Table,
} from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
import {
type JSX,
useCallback,
useDeferredValue,
useEffect,
useMemo,
useState,
} from "react";
import { base16 } from "rfc4648";
import { Input } from "@components/UI/Input.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import { useTranslation } from "react-i18next";
import useLang from "@core/hooks/useLang.ts";
export interface DeleteNoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const NodesPage = (): JSX.Element => {
const { t } = useTranslation("nodes");
const { currentLanguage } = useLang();
const { getNodes, hardware, connection, hasNodeError, setDialogOpen } =
useDevice();
const { setNodeNumDetails } = useAppStore();
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
const [selectedTraceroute, setSelectedTraceroute] = useState<
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery> | undefined
>();
const [selectedLocation, setSelectedLocation] = useState<
Types.PacketMetadata<Protobuf.Mesh.Position> | undefined
>();
const [filterState, setFilterState] = useState<FilterState>(() =>
defaultFilterValues
);
const deferredFilterState = useDeferredValue(filterState);
const filteredNodes = useMemo(
() => getNodes((node) => nodeFilter(node, deferredFilterState)),
[deferredFilterState, getNodes, nodeFilter],
);
useEffect(() => {
if (!connection) return;
connection.events.onTraceRoutePacket.subscribe(handleTraceroute);
return () => {
connection.events.onTraceRoutePacket.unsubscribe(handleTraceroute);
};
}, [connection]);
const handleTraceroute = useCallback(
(traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>) => {
setSelectedTraceroute(traceroute);
},
[],
);
useEffect(() => {
if (!connection) return;
connection.events.onPositionPacket.subscribe(handleLocation);
return () => {
connection.events.onPositionPacket.subscribe(handleLocation);
};
}, [connection]);
const handleLocation = useCallback(
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
if (location.to.valueOf() !== hardware.myNodeNum) return;
setSelectedLocation(location);
},
[hardware.myNodeNum],
);
function handleNodeInfoDialog(nodeNum: number): void {
setNodeNumDetails(nodeNum);
setDialogOpen("nodeDetails", true);
}
const tableHeadings: Heading[] = [
{ title: "", sortable: false },
{ title: t("nodesTable.headings.longName"), sortable: true },
{ title: t("nodesTable.headings.connection"), sortable: true },
{ title: t("nodesTable.headings.lastHeard"), sortable: true },
{ title: t("nodesTable.headings.encryption"), sortable: false },
{ title: t("unit.snr"), sortable: true },
{ title: t("nodesTable.headings.model"), sortable: true },
{ title: t("nodesTable.headings.macAddress"), sortable: true },
];
const tableRows: DataRow[] = filteredNodes.map((node) => {
const macAddress = base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? t("unknown.shortName");
return {
id: node.num,
isFavorite: node.isFavorite,
cells: [
{
content: (
<Avatar
text={node.user?.shortName ?? t("unknown.shortName")}
showFavorite={node.isFavorite}
showError={hasNodeError(node.num)}
/>
),
sortValue: node.user?.shortName ?? "", // Non-sortable column
},
{
content: (
<h1
onMouseDown={() => handleNodeInfoDialog(node.num)}
onKeyUp={(evt) => {
evt.key === "Enter" && handleNodeInfoDialog(node.num);
}}
className="cursor-pointer underline ml-2 whitespace-break-spaces"
tabIndex={0}
role="button"
>
{node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>
),
sortValue: node.user?.longName ?? numberToHexUnpadded(node.num),
},
{
content: (
<Mono className="w-16">
{node.hopsAway !== undefined
? node?.viaMqtt === false && node.hopsAway === 0
? t("nodesTable.connectionStatus.direct")
: `${node.hopsAway?.toString()} ${
node.hopsAway ?? 0 > 1
? t("unit.hop.plural")
: t("unit.hops_one")
} ${t("nodesTable.connectionStatus.away")}`
: t("nodesTable.connectionStatus.unknown")}
{node?.viaMqtt === true
? t("nodesTable.connectionStatus.viaMqtt")
: ""}
</Mono>
),
sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER,
},
{
content: (
<Mono>
{node.lastHeard === 0
? <p>{t("nodesTable.lastHeardStatus.never")}</p>
: (
<TimeAgo
timestamp={node.lastHeard * 1000}
locale={currentLanguage?.code}
/>
)}
</Mono>
),
sortValue: node.lastHeard,
},
{
content: (
<Mono>
{node.user?.publicKey && node.user?.publicKey.length > 0
? <LockIcon className="text-green-600 mx-auto" />
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
</Mono>
),
sortValue: "", // Non-sortable column
},
{
content: (
<Mono>
{node.snr}
{t("unit.dbm")}/
{Math.min(
Math.max((node.snr + 10) * 5, 0),
100,
)}%/{/* Percentage */}
{(node.snr + 10) * 5}
{t("unit.raw")}
</Mono>
),
sortValue: node.snr,
},
{
content: (
<Mono>
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>
),
sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0],
},
{
content: <Mono>{macAddress}</Mono>,
sortValue: macAddress,
},
],
};
});
return (
<>
<PageLayout
label=""
leftBar={<Sidebar />}
>
<div className="pl-2 pt-2 flex flex-row">
<div className="flex-1 mr-2">
<Input
placeholder={t("search.nodes")}
value={filterState.nodeName}
className="bg-transparent"
showClearButton={!!filterState.nodeName}
onChange={(e) =>
setFilterState((prev) => ({
...prev,
nodeName: e.target.value,
}))}
/>
</div>
<div className="flex justify-end">
<FilterControl
filterState={filterState}
defaultFilterValues={defaultFilterValues}
setFilterState={setFilterState}
isDirty={isFilterDirty(filterState)}
parameters={{
popoverContentProps: {
side: "bottom",
align: "end",
sideOffset: 12,
},
popoverTriggerClassName: "mr-1 p-2",
showTextSearch: false,
}}
/>
</div>
</div>
<div className="overflow-y-auto">
<Table
headings={tableHeadings}
rows={tableRows}
/>
<TracerouteResponseDialog
traceroute={selectedTraceroute}
open={!!selectedTraceroute}
onOpenChange={() => setSelectedTraceroute(undefined)}
/>
<LocationResponseDialog
location={selectedLocation}
open={!!selectedLocation}
onOpenChange={() => setSelectedLocation(undefined)}
/>
</div>
</PageLayout>
</>
);
};
export default NodesPage;