-
- {messages.map((message, index) => {
- return (
-
0 &&
- messages[index - 1].from === message.from}
- />
- );
- })}
+
+
+ {messages.map((message, index) => (
+
0 && messages[index - 1].from === message.from
+ }
+ />
+ ))}
-
-
-
+
);
-};
+};
\ No newline at end of file
diff --git a/src/components/PageComponents/Messages/MessageInput.test.tsx b/src/components/PageComponents/Messages/MessageInput.test.tsx
new file mode 100644
index 00000000..011f6e52
--- /dev/null
+++ b/src/components/PageComponents/Messages/MessageInput.test.tsx
@@ -0,0 +1,152 @@
+import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx';
+import { useDevice } from "@core/stores/deviceStore.ts";
+import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+vi.mock("@core/stores/deviceStore.ts", () => ({
+ useDevice: vi.fn(),
+}));
+
+vi.mock("@core/utils/debounce.ts", () => ({
+ debounce: (fn: () => void) => fn,
+}));
+
+vi.mock("@components/UI/Button.tsx", () => ({
+ Button: ({ children, ...props }: { children: React.ReactNode }) =>
+}));
+
+vi.mock("@components/UI/Input.tsx", () => ({
+ Input: (props: any) =>
+}));
+
+vi.mock("lucide-react", () => ({
+ SendIcon: () =>
Send
+}));
+
+// TODO: getting an error with this test
+describe('MessageInput Component', () => {
+ const mockProps = {
+ to: "broadcast" as const,
+ channel: 0 as const,
+ maxBytes: 100,
+ };
+
+ const mockSetMessageDraft = vi.fn();
+ const mockSetMessageState = vi.fn();
+ const mockSendText = vi.fn().mockResolvedValue(123);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ (useDevice as Mock).mockReturnValue({
+ connection: {
+ sendText: mockSendText,
+ },
+ setMessageState: mockSetMessageState,
+ messageDraft: "",
+ setMessageDraft: mockSetMessageDraft,
+ hardware: {
+ myNodeNum: 1234567890,
+ },
+ });
+ });
+
+ it('renders correctly with initial state', () => {
+ render(
);
+
+ expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
+ expect(screen.getByTestId('send-icon')).toBeInTheDocument();
+
+ expect(screen.getByText('0/100')).toBeInTheDocument();
+ });
+
+ it('updates local draft and byte count when typing', () => {
+ render(
);
+
+ const inputField = screen.getByPlaceholderText('Enter Message');
+ fireEvent.change(inputField, { target: { value: 'Hello' } })
+
+ expect(screen.getByText('5/100')).toBeInTheDocument();
+ expect(inputField).toHaveValue('Hello');
+ expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
+ });
+
+ it.skip('does not allow input exceeding max bytes', () => {
+ render(
);
+
+ const inputField = screen.getByPlaceholderText('Enter Message');
+
+ expect(screen.getByText('0/100')).toBeInTheDocument();
+
+ userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p')
+
+ expect(screen.getByText('100/100')).toBeInTheDocument();
+ expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
+ });
+
+ it('sends message and resets form when submitting', async () => {
+ try {
+ render(
);
+
+ const inputField = screen.getByPlaceholderText('Enter Message');
+ const submitButton = screen.getByText('Send');
+
+ fireEvent.change(inputField, { target: { value: 'Test Message' } });
+ fireEvent.click(submitButton);
+
+ const form = screen.getByRole('form');
+ fireEvent.submit(form);
+
+ expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0);
+
+ await waitFor(() => {
+ expect(mockSetMessageState).toHaveBeenCalledWith(
+ 'broadcast',
+ 0,
+ 'broadcast',
+ 1234567890,
+ 123,
+ 'ack'
+ );
+
+ });
+
+ expect(inputField).toHaveValue('');
+ expect(screen.getByText('0/100')).toBeInTheDocument();
+ expect(mockSetMessageDraft).toHaveBeenCalledWith('');
+ } catch (e) {
+ console.error(e);
+ }
+ });
+ it('prevents sending empty messages', () => {
+ render(
);
+
+ const form = screen.getByPlaceholderText('Enter Message')
+ fireEvent.submit(form);
+
+ expect(mockSendText).not.toHaveBeenCalled();
+ });
+
+ it('initializes with existing message draft', () => {
+ (useDevice as Mock).mockReturnValue({
+ connection: {
+ sendText: mockSendText,
+ },
+ setMessageState: mockSetMessageState,
+ messageDraft: "Existing draft",
+ setMessageDraft: mockSetMessageDraft,
+ isQueueingMessages: false,
+ queueStatus: { free: 10 },
+ hardware: {
+ myNodeNum: 1234567890,
+ },
+ });
+
+ render(
);
+
+ const inputField = screen.getByRole('textbox');
+
+ expect(inputField).toHaveValue('Existing draft');
+ });
+});
\ No newline at end of file
diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx
index 1e1a16ae..b885afaf 100644
--- a/src/components/PageComponents/Messages/MessageInput.tsx
+++ b/src/components/PageComponents/Messages/MessageInput.tsx
@@ -1,4 +1,4 @@
-import { debounce } from "../../../core/utils/debounce.ts";
+import { debounce } from "@core/utils/debounce.ts";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -22,6 +22,8 @@ export const MessageInput = ({
setMessageState,
messageDraft,
setMessageDraft,
+ isQueueingMessages,
+ queueStatus,
hardware,
} = useDevice();
const myNodeNum = hardware.myNodeNum;
@@ -33,8 +35,10 @@ export const MessageInput = ({
[setMessageDraft],
);
+ // sends the message to the selected destination
const sendText = useCallback(
async (message: string) => {
+
await connection
?.sendText(message, to, true, channel)
.then((id: number) =>
@@ -58,7 +62,7 @@ export const MessageInput = ({
)
);
},
- [channel, connection, myNodeNum, setMessageState, to],
+ [channel, connection, myNodeNum, setMessageState, to, queueStatus],
);
const handleInputChange = (e: React.ChangeEvent
) => {
@@ -81,15 +85,18 @@ export const MessageInput = ({
if (localDraft === "") return;
const message = formData.get("messageInput") as string;
startTransition(() => {
- sendText(message);
- setLocalDraft("");
- setMessageDraft("");
- setMessageBytes(0);
+ if (!isQueueingMessages) {
+ sendText(message);
+ setLocalDraft("");
+ setMessageDraft("");
+ setMessageBytes(0);
+ }
+
});
}}
>
-
-
+
+
-
diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx
index 5f08ca6d..132e7bc4 100644
--- a/src/components/PageLayout.tsx
+++ b/src/components/PageLayout.tsx
@@ -10,6 +10,7 @@ export interface PageLayoutProps {
label: string;
noPadding?: boolean;
children: React.ReactNode;
+ className?: string;
actions?: {
icon: LucideIcon;
iconClasses?: string;
@@ -23,6 +24,7 @@ export const PageLayout = ({
label,
noPadding,
actions,
+ className,
children,
}: PageLayoutProps) => {
return (
@@ -63,6 +65,7 @@ export const PageLayout = ({
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
+ className
)}
>
{children}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index b814827e..1d297d66 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -66,7 +66,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
return showSidebar
? (
-
+
diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts
index 0822473f..e21d59af 100644
--- a/src/core/stores/deviceStore.ts
+++ b/src/core/stores/deviceStore.ts
@@ -1,5 +1,5 @@
import { create } from "@bufbuild/protobuf";
-import { Protobuf, Types } from "@meshtastic/core";
+import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
import { produce } from "immer";
import { createContext, useContext } from "react";
import { create as createStore } from "zustand";
@@ -28,6 +28,10 @@ export type DialogVariant =
| "pkiBackup"
| "nodeDetails";
+type QueueStatus = {
+ res: number, free: number, maxlen: number
+}
+
export interface Device {
id: number;
status: Types.DeviceStatusEnum;
@@ -47,13 +51,15 @@ export interface Device {
number,
Types.PacketMetadata[]
>;
- connection?: Types.ConnectionType;
+ connection?: MeshDevice;
activePage: Page;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
// currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean;
messageDraft: string;
+ queueStatus: QueueStatus,
+ isQueueingMessages: boolean,
dialog: {
import: boolean;
QR: boolean;
@@ -65,6 +71,7 @@ export interface Device {
nodeDetails: boolean;
};
+
setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
@@ -80,7 +87,7 @@ export interface Device {
addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
addUser: (user: Types.PacketMetadata) => void;
addPosition: (position: Types.PacketMetadata) => void;
- addConnection: (connection: Types.ConnectionType) => void;
+ addConnection: (connection: MeshDevice) => void;
addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata,
@@ -98,6 +105,7 @@ export interface Device {
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void;
+ setQueueStatus: (status: QueueStatus) => void;
}
export interface DeviceState {
@@ -137,6 +145,10 @@ export const useDeviceStore = createStore((set, get) => ({
activePage: "messages",
activeNode: 0,
waypoints: [],
+ queueStatus: {
+ res: 0, free: 0, maxlen: 0
+ },
+ isQueueingMessages: false,
dialog: {
import: false,
QR: false,
@@ -303,7 +315,7 @@ export const useDeviceStore = createStore((set, get) => ({
.findIndex(
(wmc) =>
wmc.payloadVariant.case ===
- moduleConfig.payloadVariant.case,
+ moduleConfig.payloadVariant.case,
);
if (workingModuleConfigIndex !== -1) {
device.workingModuleConfig[workingModuleConfigIndex] =
@@ -516,7 +528,6 @@ export const useDeviceStore = createStore((set, get) => ({
set(
produce((draft) => {
console.log("addTraceRoute called");
- console.log(traceroute);
const device = draft.devices.get(id);
if (!device) {
return;
@@ -631,6 +642,17 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
+ setQueueStatus: (status: QueueStatus) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (device) {
+ device.queueStatus = status;
+ device.queueStatus.free >= 10 ? true : false
+ }
+ }),
+ );
+ }
});
}),
);
diff --git a/src/core/subscriptions.ts b/src/core/subscriptions.ts
index 8f1c1a09..4b09a854 100644
--- a/src/core/subscriptions.ts
+++ b/src/core/subscriptions.ts
@@ -1,9 +1,9 @@
import type { Device } from "@core/stores/deviceStore.ts";
-import { Protobuf, type Types } from "@meshtastic/core";
+import { MeshDevice, Protobuf } from "@meshtastic/core";
export const subscribeAll = (
device: Device,
- connection: Types.ConnectionType,
+ connection: MeshDevice,
) => {
let myNodeNum = 0;
@@ -70,6 +70,8 @@ export const subscribeAll = (
});
connection.events.onChannelPacket.subscribe((channel) => {
+ console.log('channel', channel);
+
device.addChannel(channel);
});
connection.events.onConfigPacket.subscribe((config) => {
@@ -80,6 +82,9 @@ export const subscribeAll = (
});
connection.events.onMessagePacket.subscribe((messagePacket) => {
+
+ console.log('messagePacket', messagePacket);
+
device.addMessage({
...messagePacket,
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
@@ -103,4 +108,11 @@ export const subscribeAll = (
time: meshPacket.rxTime,
});
});
+
+ connection.events.onQueueStatus.subscribe((queueStatus) => {
+ device.setQueueStatus(queueStatus);
+ if (queueStatus.free < 10) {
+ // start queueing messages
+ }
+ });
};
diff --git a/src/index.css b/src/index.css
index 7c1ffc76..d81c2967 100644
--- a/src/index.css
+++ b/src/index.css
@@ -71,6 +71,7 @@
}
@layer base {
+
*,
::after,
::before,
@@ -96,11 +97,23 @@
}
}
+@layer utilities {
+ .scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+
+ .scrollbar-hide::-webkit-scrollbar {
+ display: none;
+ }
+}
+
/* Prevent image dragging */
img {
-webkit-user-drag: none;
}
+
@keyframes spin-slower {
to {
transform: rotate(360deg);
@@ -109,4 +122,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 b520f906..008f69e2 100644
--- a/src/pages/Messages.tsx
+++ b/src/pages/Messages.tsx
@@ -12,6 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useState } from "react";
+import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
export const MessagesPage = () => {
const { channels, nodes, hardware, messages } = useDevice();
@@ -31,6 +32,11 @@ export const MessagesPage = () => {
const node = nodes.get(activeChat);
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
+ const messageDestination = chatType === "direct" ? activeChat : "broadcast";
+ const messageChannel = chatType === "direct"
+ ? Types.ChannelNumber.Primary
+ : activeChat;
+
return (
<>
@@ -41,9 +47,9 @@ export const MessagesPage = () => {
label={channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
- ? "Primary"
- : `Ch ${channel.index}`}
- active={activeChat === channel.index}
+ ? "Primary"
+ : `Ch ${channel.index}`}
+ active={activeChat === channel.index && chatType === "broadcast"}
onClick={() => {
setChatType("broadcast");
setActiveChat(channel.index);
@@ -68,7 +74,7 @@ export const MessagesPage = () => {
key={node.num}
label={node.user?.longName ??
`!${numberToHexUnpadded(node.num)}`}
- active={activeChat === node.num}
+ active={activeChat === node.num && chatType === "direct"}
onClick={() => {
setChatType("direct");
setActiveChat(node.num);
@@ -84,15 +90,15 @@ export const MessagesPage = () => {
-
+
{
]
: []}
>
- {allChannels.map(
- (channel) =>
- activeChat === channel.index && (
-
- ),
- )}
- {filteredNodes.map(
- (node) =>
- activeChat === node.num && (
-
- ),
- )}
+
+ {chatType === "broadcast" && currentChannel && (
+
+ )}
+
+ {chatType === "direct" && node && (
+
+ )}
+
+
+ {/* Single message input for both chat types */}
+
+
+
>
);
};
-export default MessagesPage;
+export default MessagesPage;
\ No newline at end of file
diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx
index 9f96b987..0a786e3d 100644
--- a/src/pages/Nodes.tsx
+++ b/src/pages/Nodes.tsx
@@ -11,7 +11,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, type Types } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { LockIcon, LockOpenIcon } from "lucide-react";
-import { Fragment, type JSX, useCallback, useEffect, useState } from "react";
+import { type JSX, useCallback, useEffect, useState } from "react";
import { base16 } from "rfc4648";
export interface DeleteNoteDialogProps {
@@ -21,6 +21,8 @@ export interface DeleteNoteDialogProps {
const NodesPage = (): JSX.Element => {
const { nodes, hardware, connection } = useDevice();
+ console.log(connection);
+
const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined
>(undefined);
@@ -61,6 +63,7 @@ const NodesPage = (): JSX.Element => {
};
}, [connection]);
+
const handleLocation = useCallback(
(location: Types.PacketMetadata
) => {
if (location.to.valueOf() !== hardware.myNodeNum) return;
@@ -108,8 +111,8 @@ const NodesPage = (): JSX.Element => {
{node.user?.shortName ??
(node.user?.macaddr
? `${base16
- .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
- .toLowerCase()}`
+ .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
+ .toLowerCase()}`
: `${numberToHexUnpadded(node.num).slice(-4)}`)}
,
@@ -121,8 +124,8 @@ const NodesPage = (): JSX.Element => {
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${base16
- .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
- .toLowerCase()}`
+ .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
+ .toLowerCase()}`
: `!${numberToHexUnpadded(node.num)}`)}
,
@@ -158,9 +161,8 @@ const NodesPage = (): JSX.Element => {
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
- : `${node.hopsAway?.toString()} ${
- node.hopsAway > 1 ? "hops" : "hop"
- } away`
+ : `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop"
+ } away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
,
diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts
index d9295846..245195c7 100644
--- a/src/tests/setupTests.ts
+++ b/src/tests/setupTests.ts
@@ -1,7 +1,18 @@
-import "@testing-library/jest-dom";
+// Try this import style instead
+import { expect, afterEach } from 'vitest';
+import { cleanup } from '@testing-library/react';
+import * as matchers from '@testing-library/jest-dom/matchers';
-globalThis.ResizeObserver = class {
+// Add the matchers (should work with * as import)
+expect.extend(matchers);
+
+// Mock ResizeObserver
+global.ResizeObserver = class {
observe() { }
unobserve() { }
disconnect() { }
-};
\ No newline at end of file
+};
+
+afterEach(() => {
+ cleanup();
+});
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index 6a117c37..6a7d3f06 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,8 +1,8 @@
-import { defineConfig } from 'vite';
+import { defineConfig } from "vite";
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
-import path from 'node:path';
import { execSync } from 'node:child_process';
+import path from "node:path";
let hash = '';
try {
@@ -19,7 +19,7 @@ export default defineConfig({
registerType: 'autoUpdate',
strategies: 'generateSW',
devOptions: {
- enabled: true
+ enabled: false
},
workbox: {
cleanupOutdatedCaches: true,
@@ -50,11 +50,4 @@ export default defineConfig({
optimizeDeps: {
exclude: ['react-scan']
},
- test: {
- environment: 'jsdom',
- globals: true,
- include: ['**/*.{test,spec}.{ts,tsx}'],
- setupFiles: ["./src/tests/setupTests.ts"],
-
- }
});
\ No newline at end of file
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 00000000..e55b9841
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,24 @@
+import path from "node:path";
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ plugins: [
+ react(),
+ ],
+ resolve: {
+ alias: {
+ '@app': path.resolve(process.cwd(), './src'),
+ '@pages': path.resolve(process.cwd(), './src/pages'),
+ '@components': path.resolve(process.cwd(), './src/components'),
+ '@core': path.resolve(process.cwd(), './src/core'),
+ '@layouts': path.resolve(process.cwd(), './src/layouts'),
+ },
+ },
+ test: {
+ globals: true,
+ include: ['src/**/*.test.tsx', 'src/**/*.test.ts'],
+ setupFiles: ['src/tests/setupTests.ts'],
+ environment: 'happy-dom',
+ },
+})
\ No newline at end of file