{
- channelSet?.settings.map((ch: unknown, index: number) => {
- connection?.setChannel(
- create(Protobuf.Channel.ChannelSchema, {
- index,
- role: index === 0
- ? Protobuf.Channel.Channel_Role.PRIMARY
- : Protobuf.Channel.Channel_Role.SECONDARY,
- settings: ch,
- }),
- );
- });
+ channelSet?.settings.map(
+ (ch: Protobuf.Channel.ChannelSettings, index: number) => {
+ connection?.setChannel(
+ create(Protobuf.Channel.ChannelSchema, {
+ index,
+ role: index === 0
+ ? Protobuf.Channel.Channel_Role.PRIMARY
+ : Protobuf.Channel.Channel_Role.SECONDARY,
+ settings: ch,
+ }),
+ );
+ },
+ );
if (channelSet?.loraConfig) {
connection?.setConfig(
diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx
index bd376de3..eb6d5e25 100644
--- a/src/components/Dialog/LocationResponseDialog.tsx
+++ b/src/components/Dialog/LocationResponseDialog.tsx
@@ -12,7 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps {
- location: Types.PacketMetadata
| undefined;
+ location: Types.PacketMetadata | undefined;
open: boolean;
onOpenChange: () => void;
}
@@ -33,6 +33,13 @@ export const LocationResponseDialog = ({
? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName"));
+ const position = location?.data;
+
+ const hasCoordinates = position &&
+ typeof position.latitudeI === "number" &&
+ typeof position.longitudeI === "number" &&
+ typeof position.altitude === "number";
+
return (
diff --git a/src/components/PageComponents/Connect/BLE.tsx b/src/components/PageComponents/Connect/BLE.tsx
index 172f4497..28aec612 100644
--- a/src/components/PageComponents/Connect/BLE.tsx
+++ b/src/components/PageComponents/Connect/BLE.tsx
@@ -5,8 +5,9 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
-import { BleConnection, ServiceUuid } from "@meshtastic/js";
-import { BluetoothDevice } from "web-bluetooth";
+import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
+import { MeshDevice } from "@meshtastic/core";
+import type { BluetoothDevice } from "web-bluetooth";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next";
@@ -31,15 +32,13 @@ export const BLE = (
const onConnect = async (bleDevice: BluetoothDevice) => {
const id = randId();
+ const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id);
+ const connection = new MeshDevice(transport, id);
+ connection.configure();
setSelectedDevice(id);
- const connection = new BleConnection(id);
- await connection.connect({
- device: bleDevice,
- });
device.addConnection(connection);
subscribeAll(device, connection, messageStore);
-
closeDialog();
};
@@ -72,7 +71,7 @@ export const BLE = (
onClick={async () => {
await navigator.bluetooth
.requestDevice({
- filters: [{ services: [ServiceUuid] }],
+ filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
})
.then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id);
diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx
index 1d6c9fd9..9a0f7298 100644
--- a/src/components/PageComponents/Messages/TraceRoute.test.tsx
+++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx
@@ -70,7 +70,7 @@ describe("TraceRoute", () => {
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
- } as any);
+ });
});
it("renders the route to destination with SNR values", () => {
@@ -135,20 +135,4 @@ describe("TraceRoute", () => {
// Check for translated '??' placeholder
expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2);
});
-
- it("renders hop hex if node is not found", () => {
- render(
- ,
- );
-
- // Check for translated '!' prefix
- expect(screen.getByText("!63")).toBeInTheDocument();
- expect(screen.getByText("↓ 5dBm")).toBeInTheDocument();
- expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
- });
});
diff --git a/src/core/utils/test.tsx b/src/core/utils/test.tsx
deleted file mode 100644
index cb9906ae..00000000
--- a/src/core/utils/test.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import {
- createMemoryHistory,
- createRouter,
- Outlet,
- RootRoute,
- Route,
- RouterProvider,
-} from "@tanstack/react-router";
-import { render as rtlRender, RenderOptions } from "@testing-library/react";
-import type { FunctionComponent, ReactElement, ReactNode } from "react";
-
-// a root route for the test router.
-const rootRoute = new RootRoute({
- component: () => (
- <>
-
- >
- ),
-});
-
-interface CustomRenderOptions extends Omit {
- initialEntries?: string[];
- ui?: ReactElement;
-}
-
-let currentRouter: ReturnType | null = null;
-
-/**
- * Custom render function for testing components that need TanStack Router context.
- * @param ui The main ReactElement to render (your component under test).
- * @param options Custom render options including initialEntries for the router.
- * @returns An object containing the testing-library render result and the router instance.
- */
-const customRender = (
- ui: ReactElement,
- options: CustomRenderOptions = {},
-) => {
- const { initialEntries = ["/"], ...renderOptions } = options;
-
- // A specific route that renders the component under test (ui).
- // It defaults to the first path in initialEntries or '/'.
- const testComponentRoute = new Route({
- getParentRoute: () => rootRoute,
- path: initialEntries[0] || "/",
- component: () => ui, // The component passed to render will be the element for this route
- });
-
- const routeTree = rootRoute.addChildren([testComponentRoute]);
-
- const router = createRouter({
- history: createMemoryHistory({ initialEntries }),
- routeTree,
- // You can add default error components or other router options if needed for tests.
- // defaultErrorComponent: ({ error }) => Test Error: {error.message}
,
- });
-
- currentRouter = router; // Store the router instance for access in tests
-
- const Wrapper: FunctionComponent<{ children?: ReactNode }> = (
- { children },
- ) => {
- return (
- <>
-
- {children}
- >
- );
- };
-
- const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
-
- return {
- ...renderResult,
- router,
- };
-};
-
-export * from "@testing-library/react";
-export { customRender as render };
-export const getTestRouter = () => currentRouter;
diff --git a/src/index.css b/src/index.css
index 41165535..56103edb 100644
--- a/src/index.css
+++ b/src/index.css
@@ -93,7 +93,6 @@ body {
}
@layer base {
-
*,
::after,
::before,
@@ -139,4 +138,4 @@ img {
.animate-spin-slow {
animation: spin-slower 2s linear infinite;
-}
\ No newline at end of file
+}
diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx
index 95d167b9..6a13fb35 100644
--- a/src/pages/Messages.tsx
+++ b/src/pages/Messages.tsx
@@ -28,9 +28,19 @@ 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 (
+
+ {t("selectChatPrompt.text", { ns: "messages" })}
+
+ );
+}
+
export const MessagesPage = () => {
const {
channels,
@@ -46,7 +56,9 @@ export const MessagesPage = () => {
getMessages,
setMessageState,
} = useMessageStore();
- const params = useParams({ from: "", shouldThrow: false });
+
+ const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
+
const navigate = useNavigate();
const { toast } = useToast();
const { isCollapsed } = useSidebar();
@@ -54,34 +66,33 @@ export const MessagesPage = () => {
const { t } = useTranslation(["messages", "channels", "ui"]);
const deferredSearch = useDeferredValue(searchTerm);
- const chatType = params.type === "direct"
+ 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
- : params.type === "broadcast"
- ? MessageType.Broadcast
- : undefined;
- const activeChat = params.chatId ? Number(params.chatId) : undefined;
+ : MessageType.Broadcast;
+ const numericChatId = Number(chatId);
const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
- const currentChannel = channels.get(activeChat);
- const otherNode = getNode(activeChat);
-
- const isDirect = chatType === MessageType.Direct;
- const isBroadcast = chatType === MessageType.Broadcast;
-
- const navigateToChat = useCallback((type: MessageType, chatId: number) => {
- const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
- navigate({ to: `/messages/${typeParam}/${chatId}` });
- }, [navigate]);
useEffect(() => {
- if (!params.type && !params.chatId && filteredChannels.length > 0) {
+ if (!type && !chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[0];
- navigateToChat(MessageType.Broadcast, defaultChannel.index);
+ navigateToChat(MessageType.Broadcast, defaultChannel.index.toString());
}
- }, [params.type, params.chatId, filteredChannels, navigateToChat]);
+ }, [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();
@@ -104,12 +115,8 @@ export const MessagesPage = () => {
};
const sendText = useCallback(async (message: string) => {
- const isDirect = chatType === MessageType.Direct;
- const toValue = isDirect ? activeChat : MessageType.Broadcast;
-
- const channelValue = isDirect
- ? Types.ChannelNumber.Primary
- : activeChat ?? 0;
+ const toValue = isDirect ? numericChatId : MessageType.Broadcast;
+ const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
let messageId: number | undefined;
@@ -123,16 +130,16 @@ export const MessagesPage = () => {
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({
- type: chatType,
+ type: MessageType.Broadcast,
channelId: channelValue,
messageId,
newState: MessageState.Ack,
});
} else {
setMessageState({
- type: chatType,
+ type: MessageType.Direct,
nodeA: getMyNodeNum(),
- nodeB: activeChat,
+ nodeB: numericChatId,
messageId,
newState: MessageState.Ack,
});
@@ -140,28 +147,34 @@ export const MessagesPage = () => {
} else {
console.warn("sendText completed but messageId is undefined");
}
- } catch (e: any) {
+ } catch (e: unknown) {
console.error("Failed to send message:", e);
const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) {
setMessageState({
- type: chatType,
+ type: MessageType.Broadcast,
channelId: channelValue,
messageId: failedId,
newState: MessageState.Failed,
});
- } else { // MessageType.Direct
- const failedId = messageId ?? randId();
+ } else {
setMessageState({
- type: chatType,
+ type: MessageType.Direct,
nodeA: getMyNodeNum(),
- nodeB: activeChat,
+ nodeB: numericChatId,
messageId: failedId,
newState: MessageState.Failed,
});
}
}
- }, [activeChat, chatType, connection, getMyNodeNum, setMessageState]);
+ }, [
+ numericChatId,
+ chatId,
+ chatType,
+ connection,
+ getMyNodeNum,
+ setMessageState,
+ ]);
const renderChatContent = () => {
switch (chatType) {
@@ -170,7 +183,7 @@ export const MessagesPage = () => {
);
@@ -180,16 +193,12 @@ export const MessagesPage = () => {
messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
- nodeB: activeChat,
+ nodeB: numericChatId,
}).reverse()}
/>
);
default:
- return (
-
- {t("selectChatPrompt.text", { ns: "messages" })}
-
- );
+ return ;
}
};
@@ -210,10 +219,10 @@ export const MessagesPage = () => {
index: channel.index,
ns: "channels",
}))}
- active={activeChat === channel.index &&
+ active={numericChatId === channel.index &&
chatType === MessageType.Broadcast}
onClick={() => {
- navigateToChat(MessageType.Broadcast, channel.index);
+ navigateToChat(MessageType.Broadcast, channel.index.toString());
resetUnread(channel.index);
}}
>
@@ -228,11 +237,12 @@ export const MessagesPage = () => {
), [
filteredChannels,
unreadCounts,
- activeChat,
+ numericChatId,
chatType,
isCollapsed,
navigateToChat,
resetUnread,
+ t,
]);
const rightSidebar = useMemo(
@@ -262,10 +272,10 @@ export const MessagesPage = () => {
label={node.user?.longName ??
t("unknown.shortName")}
count={node.unreadCount > 0 ? node.unreadCount : undefined}
- active={activeChat === node.num &&
+ active={numericChatId === node.num &&
chatType === MessageType.Direct}
onClick={() => {
- navigateToChat(MessageType.Direct, node.num);
+ navigateToChat(MessageType.Direct, node.num.toString());
resetUnread(node.num);
}}
>
@@ -285,11 +295,12 @@ export const MessagesPage = () => {
[
filteredNodes,
searchTerm,
- activeChat,
+ numericChatId,
chatType,
navigateToChat,
resetUnread,
hasNodeError,
+ t,
],
);
@@ -330,7 +341,7 @@ export const MessagesPage = () => {
{(isBroadcast || isDirect)
? (
diff --git a/src/routes.tsx b/src/routes.tsx
index f936fd12..6cdbb7da 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -4,10 +4,11 @@ import MessagesPage from "@pages/Messages.tsx";
import MapPage from "@pages/Map/index.tsx";
import ConfigPage from "@pages/Config/index.tsx";
import ChannelsPage from "@pages/Channels.tsx";
-import NodesPage from "@pages/Nodes.tsx";
+import NodesPage from "@pages/Nodes/index.tsx";
import { createRootRoute } from "@tanstack/react-router";
import { App } from "./App.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
+import { z } from "zod";
const rootRoute = createRootRoute({
component: App,
@@ -27,12 +28,42 @@ const messagesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/messages",
component: MessagesPage,
+ beforeLoad: ({ params }) => {
+ const DEFAULT_CHANNEL = 0;
+
+ if (Object.values(params).length === 0) {
+ throw redirect({
+ to: `/messages/broadcast/${DEFAULT_CHANNEL}`,
+ replace: true,
+ });
+ }
+ },
+});
+
+const chatIdSchema = z.string().refine((val) => {
+ const num = Number(val);
+ if (isNaN(num) || !Number.isInteger(num)) {
+ return false;
+ }
+
+ const isChannelId = num >= 0 && num <= 10;
+ const isNodeId = num >= 1000000000 && num <= 9999999999;
+
+ return isChannelId || isNodeId;
+}, {
+ message: "Chat ID must be a channel (0-10) or a valid node ID.",
});
-const messagesWithParamsRoute = createRoute({
+export const messagesWithParamsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/messages/$type/$chatId",
component: MessagesPage,
+ parseParams: (params) => ({
+ type: z.enum(["direct", "broadcast"], {
+ errorMap: () => ({ message: 'Type must be "direct" or "broadcast".' }),
+ }).parse(params.type),
+ chatId: chatIdSchema.parse(params.chatId),
+ }),
});
const mapRoute = createRoute({
diff --git a/src/tests/setupTests.ts b/src/tests/setup.ts
similarity index 100%
rename from src/tests/setupTests.ts
rename to src/tests/setup.ts
diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx
new file mode 100644
index 00000000..e28d5a11
--- /dev/null
+++ b/src/tests/test-utils.tsx
@@ -0,0 +1,37 @@
+import { ReactElement } from "react";
+import { render, RenderOptions } from "@testing-library/react";
+import {
+ createMemoryHistory,
+ createRouter,
+ RouterProvider,
+} from "@tanstack/react-router";
+import "../i18n/config.ts";
+import { routeTree } from "../routeTree.gen.ts";
+
+import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
+
+const Providers = () => {
+ const memoryHistory = createMemoryHistory({
+ initialEntries: ["/"],
+ });
+
+ const router = createRouter({
+ routeTree,
+ history: memoryHistory,
+ });
+
+ return (
+
+
+
+ );
+};
+
+const renderWithProviders = (
+ ui: ReactElement,
+ options?: Omit,
+) => render(ui, { wrapper: Providers, ...options });
+
+export * from "@testing-library/react";
+
+export { renderWithProviders as render };
diff --git a/vitest.config.ts b/vitest.config.ts
index 537096e0..ec6d3d71 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -3,6 +3,8 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import { enableMapSet } from "immer";
+import process from "node:process";
+
enableMapSet();
export default defineConfig({
plugins: [
@@ -25,6 +27,6 @@ export default defineConfig({
restoreMocks: true,
root: path.resolve(process.cwd(), "./src"),
include: ["**/*.{test,spec}.{ts,tsx}"],
- setupFiles: ["./src/tests/setupTests.ts", "./src/core/utils/test.tsx"],
+ setupFiles: ["./src/tests/setup.ts"],
},
});