chore: lint/format all files (#604)

* chore: lint/format all files

* Fix config sidebar button state (#602)

* chore: Update deno.lock version and add Radix UI slider component (#601)

* fix: improve how table addresses even/odd rows

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
This commit is contained in:
Dan Ditomaso
2025-05-04 15:55:04 -04:00
committed by GitHub
co-authored by philon- Kamil Dzieniszewski
parent b7bdb1a502
commit 480ca46a95
104 changed files with 3104 additions and 2527 deletions
+9 -4
View File
@@ -99,8 +99,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
psk: pass,
moduleSettings: {
...channel?.settings?.moduleSettings,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
}
positionPrecision:
channel?.settings?.moduleSettings?.positionPrecision ===
undefined
? 10
: channel?.settings?.moduleSettings?.positionPrecision,
},
},
},
}}
@@ -125,7 +129,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
{
type: "passwordGenerator",
name: "settings.psk",
id: 'channel-psk',
id: "channel-psk",
label: "Pre-Shared Key",
description:
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
@@ -212,7 +216,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
text={{
button: "Regenerate",
title: "Regenerate Pre-Shared Key?",
description: "Are you sure you want to regenerate the pre-shared key?",
description:
"Are you sure you want to regenerate the pre-shared key?",
}}
open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)}
@@ -1,21 +1,21 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Device } from '@components/PageComponents/Config/Device/index.tsx';
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Device } from "@components/PageComponents/Config/Device/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore.ts', () => ({
useDevice: vi.fn()
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(),
}));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({
useUnsafeRolesDialog: vi.fn()
vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({
useUnsafeRolesDialog: vi.fn(),
}));
// Mock the DynamicForm component since we're testing the Device component,
// not the DynamicForm implementation
vi.mock('@components/Form/DynamicForm', () => ({
vi.mock("@components/Form/DynamicForm", () => ({
DynamicForm: vi.fn(({ onSubmit }) => {
// Render a simplified version of the form for testing
return (
@@ -28,13 +28,16 @@ vi.mock('@components/Form/DynamicForm', () => ({
onSubmit(mockData);
}}
>
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map((
[key, value],
) => (
<option key={key} value={value}>
{key}
</option>
))}
</select>
<button type="submit"
<button
type="submit"
data-testid="submit-button"
onClick={() => onSubmit({ role: "CLIENT" })}
>
@@ -42,10 +45,10 @@ vi.mock('@components/Form/DynamicForm', () => ({
</button>
</div>
);
})
}),
}));
describe('Device component', () => {
describe("Device component", () => {
const setWorkingConfigMock = vi.fn();
const validateRoleSelectionMock = vi.fn();
const mockDeviceConfig = {
@@ -63,17 +66,17 @@ describe('Device component', () => {
vi.resetAllMocks();
// Mock the useDevice hook
(useDevice as any).mockReturnValue({
useDevice.mockReturnValue({
config: {
device: mockDeviceConfig
device: mockDeviceConfig,
},
setWorkingConfig: setWorkingConfigMock
setWorkingConfig: setWorkingConfigMock,
});
// Mock the useUnsafeRolesDialog hook
validateRoleSelectionMock.mockResolvedValue(true);
(useUnsafeRolesDialog as any).mockReturnValue({
validateRoleSelection: validateRoleSelectionMock
useUnsafeRolesDialog.mockReturnValue({
validateRoleSelection: validateRoleSelectionMock,
});
});
@@ -81,49 +84,48 @@ describe('Device component', () => {
vi.clearAllMocks();
});
it('should render the Device form', () => {
it("should render the Device form", () => {
render(<Device />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
});
it('should use the validateRoleSelection from the unsafe roles hook', () => {
it("should use the validateRoleSelection from the unsafe roles hook", () => {
render(<Device />);
expect(useUnsafeRolesDialog).toHaveBeenCalled();
});
it('should call setWorkingConfig when form is submitted', async () => {
it("should call setWorkingConfig when form is submitted", async () => {
render(<Device />);
fireEvent.click(screen.getByTestId('submit-button'));
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.objectContaining({ role: "CLIENT" })
}
})
value: expect.objectContaining({ role: "CLIENT" }),
},
}),
);
});
});
it('should create config with proper structure', async () => {
it("should create config with proper structure", async () => {
render(<Device />);
// Simulate form submission
fireEvent.click(screen.getByTestId('submit-button'));
fireEvent.click(screen.getByTestId("submit-button"));
await waitFor(() => {
expect(setWorkingConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
payloadVariant: {
case: "device",
value: expect.any(Object)
}
})
value: expect.any(Object),
},
}),
);
});
});
});
});
@@ -16,7 +16,7 @@ export const Device = () => {
case: "device",
value: data,
},
})
}),
);
};
return (
@@ -83,16 +83,16 @@ export const Device = () => {
description: "Disable triple click",
},
{
type: 'text',
name: 'tzdef',
label: 'POSIX Timezone',
description: 'The POSIX timezone string for the device',
type: "text",
name: "tzdef",
label: "POSIX Timezone",
description: "The POSIX timezone string for the device",
properties: {
fieldLength: {
max: 64,
currentValueLength: config.device?.tzdef?.length,
showCharacterCount: true,
}
},
},
},
{
@@ -1,131 +1,24 @@
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// import { render, screen, fireEvent, waitFor } from '@testing-library/react';
// import { Network } from '@components/PageComponents/Config/Network/index.tsx';
// import { useDevice } from "@core/stores/deviceStore.ts";
// import { Protobuf } from "@meshtastic/core";
// vi.mock('@core/stores/deviceStore', () => ({
// useDevice: vi.fn()
// }));
// vi.mock('@components/Form/DynamicForm', () => ({
// DynamicForm: vi.fn(({ onSubmit }) => {
// return (
// <div data-testid="dynamic-form">
// <select
// data-testid="role-select"
// onChange={(e) => {
// const mockData = { role: e.target.value };
// onSubmit(mockData);
// }}
// >
// {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
// <option key={key} value={value}>
// {key}
// </option>
// ))}
// </select>
// <button type="submit"
// data-testid="submit-button"
// onClick={() => onSubmit({ role: "CLIENT" })}
// >
// Submit
// </button>
// </div>
// );
// })
// }));
// describe('Network component', () => {
// const setWorkingConfigMock = vi.fn();
// const mockDeviceConfig = {
// role: "CLIENT",
// buttonGpio: 0,
// buzzerGpio: 0,
// rebroadcastMode: "ALL",
// nodeInfoBroadcastSecs: 300,
// doubleTapAsButtonPress: false,
// disableTripleClick: false,
// ledHeartbeatDisabled: false,
// };
// beforeEach(() => {
// vi.resetAllMocks();
// (useDevice as any).mockReturnValue({
// config: {
// device: mockDeviceConfig
// },
// setWorkingConfig: setWorkingConfigMock
// });
// });
// afterEach(() => {
// vi.clearAllMocks();
// });
// it('should render the Network form', () => {
// render(<Network />);
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
// });
// it('should call setWorkingConfig when form is submitted', async () => {
// render(<Network />);
// fireEvent.click(screen.getByTestId('submit-button'));
// await waitFor(() => {
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
// expect.objectContaining({
// payloadVariant: {
// case: "device",
// value: expect.objectContaining({ role: "CLIENT" })
// }
// })
// );
// });
// });
// it('should create config with proper structure', async () => {
// render(<Network />);
// // Simulate form submission
// fireEvent.click(screen.getByTestId('submit-button'));
// await waitFor(() => {
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
// expect.objectContaining({
// payloadVariant: {
// case: "network",
// value: expect.any(Object)
// }
// })
// );
// });
// });
// });
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Network } from '@components/PageComponents/Config/Network/index.tsx';
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Network } from "@components/PageComponents/Config/Network/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({
useDevice: vi.fn()
vi.mock("@core/stores/deviceStore", () => ({
useDevice: vi.fn(),
}));
vi.mock('@components/Form/DynamicForm', async () => {
const React = await import('react');
vi.mock("@components/Form/DynamicForm", async () => {
const React = await import("react");
const { useState } = React;
return {
DynamicForm: ({ onSubmit, defaultValues }: any) => {
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false);
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? '');
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? '');
DynamicForm: ({ onSubmit, defaultValues }) => {
const [wifiEnabled, setWifiEnabled] = useState(
defaultValues.wifiEnabled ?? false,
);
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? "");
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? "");
return (
<form
@@ -166,15 +59,14 @@ vi.mock('@components/Form/DynamicForm', async () => {
},
};
});
;
describe('Network component', () => {
describe("Network component", () => {
const setWorkingConfigMock = vi.fn();
const mockNetworkConfig = {
wifiEnabled: false,
wifiSsid: '',
wifiPsk: '',
ntpServer: '',
wifiSsid: "",
wifiPsk: "",
ntpServer: "",
ethEnabled: false,
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
ipv4Config: {
@@ -185,17 +77,17 @@ describe('Network component', () => {
},
enabledProtocols:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
rsyslogServer: '',
rsyslogServer: "",
};
beforeEach(() => {
vi.resetAllMocks();
(useDevice as any).mockReturnValue({
useDevice.mockReturnValue({
config: {
network: mockNetworkConfig
network: mockNetworkConfig,
},
setWorkingConfig: setWorkingConfigMock
setWorkingConfig: setWorkingConfigMock,
});
});
@@ -203,21 +95,21 @@ describe('Network component', () => {
vi.clearAllMocks();
});
it('should render the Network form', () => {
it("should render the Network form", () => {
render(<Network />);
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument();
});
it('should disable SSID and PSK fields when wifi is off', () => {
it("should disable SSID and PSK fields when wifi is off", () => {
render(<Network />);
expect(screen.getByLabelText("SSID")).toBeDisabled();
expect(screen.getByLabelText("PSK")).toBeDisabled();
});
it('should enable SSID and PSK when wifi is toggled on', async () => {
it("should enable SSID and PSK when wifi is toggled on", async () => {
render(<Network />);
const toggle = screen.getByLabelText("WiFi Enabled");
screen.debug()
screen.debug();
fireEvent.click(toggle); // turns wifiEnabled = true
@@ -227,7 +119,7 @@ describe('Network component', () => {
});
});
it('should call setWorkingConfig with the right structure on submit', async () => {
it("should call setWorkingConfig with the right structure on submit", async () => {
render(<Network />);
fireEvent.click(screen.getByTestId("submit-button"));
@@ -239,28 +131,28 @@ describe('Network component', () => {
case: "network",
value: expect.objectContaining({
wifiEnabled: false,
wifiSsid: '',
wifiPsk: '',
ntpServer: '',
wifiSsid: "",
wifiPsk: "",
ntpServer: "",
ethEnabled: false,
rsyslogServer: '',
})
}
})
rsyslogServer: "",
}),
},
}),
);
});
});
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => {
it("should submit valid data after enabling wifi and entering SSID and PSK", async () => {
render(<Network />);
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
fireEvent.change(screen.getByLabelText("SSID"), {
target: { value: "MySSID" }
target: { value: "MySSID" },
});
fireEvent.change(screen.getByLabelText("PSK"), {
target: { value: "MySecretPSK" }
target: { value: "MySecretPSK" },
});
fireEvent.click(screen.getByTestId("submit-button"));
@@ -273,10 +165,10 @@ describe('Network component', () => {
value: expect.objectContaining({
wifiEnabled: true,
wifiSsid: "MySSID",
wifiPsk: "MySecretPSK"
})
}
})
wifiPsk: "MySecretPSK",
}),
},
}),
);
});
});
@@ -1,4 +1,7 @@
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts";
import {
type NetworkValidation,
NetworkValidationSchema,
} from "@app/validation/config/network.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -55,8 +58,8 @@ export const Network = () => {
),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST
enabledProtocols: config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}}
fieldGroups={[
{
@@ -183,10 +186,9 @@ export const Network = () => {
name: "enabledProtocols",
label: "Mesh via UDP",
properties: {
enumValue:
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true,
}
},
},
],
},
@@ -74,7 +74,7 @@ export const Position = () => {
name: "positionFlags",
value: activeFlags,
isChecked: (name: string) =>
activeFlags?.includes(name as FlagName) ?? false,
activeFlags?.includes(name as FlagName) ?? false,
onValueChange: onPositonFlagChange,
label: "Position Flags",
placeholder: "Select position flags...",
@@ -1,16 +1,12 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import {
getX25519PrivateKey,
getX25519PublicKey,
} from "@core/utils/x25519.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import type { SecurityValidation } from "@app/validation/config/security.ts";
import { create } from "@bufbuild/protobuf";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import { Eye, EyeOff } from "lucide-react";
import { useReducer } from "react";
import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx";
@@ -58,7 +54,8 @@ export const Security = () => {
if (input.length % 4 !== 0) {
addError(
fieldName,
`${fieldName === "privateKey" ? "Private" : "Admin"
`${
fieldName === "privateKey" ? "Private" : "Admin"
} Key is required to be a 256 bit pre-shared key (PSK)`,
);
return;
@@ -73,7 +70,8 @@ export const Security = () => {
console.error(e);
addError(
fieldName,
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin"
`Invalid ${
fieldName === "privateKey" ? "Private" : "Admin"
} Key format`,
);
}
@@ -242,7 +240,7 @@ export const Security = () => {
? getErrorMessage("adminKey")
: "",
inputChange: adminKeyInputChangeEvent,
selectChange: () => { },
selectChange: () => {},
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
devicePSKBitCount: state.privateKeyBitCount,
hide: !state.adminKeyVisible,
@@ -9,10 +9,12 @@ import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
export const BLE = (
{ setConnectionInProgress, closeDialog }: TabElementProps,
) => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore()
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => {
@@ -59,8 +61,6 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
<Button
variant="default"
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [ServiceUuid] }],
@@ -82,4 +82,4 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
</Button>
</div>
);
};
};
@@ -1,15 +1,17 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { vi, describe, it, expect } from "vitest";
import { describe, expect, it, vi } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
}));
vi.mock("@core/stores/deviceStore.ts", () => ({
useDeviceStore: vi.fn(() => ({ addDevice: vi.fn(() => ({ addConnection: vi.fn() })) })),
useDeviceStore: vi.fn(() => ({
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
})),
}));
vi.mock("@core/utils/randId.ts", () => ({
@@ -28,13 +30,13 @@ vi.mock("@meshtastic/core", () => ({
})),
}));
describe("HTTP Component", () => {
it("renders correctly", () => {
render(<HTTP closeDialog={vi.fn()} />);
expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
});
@@ -42,8 +44,9 @@ describe("HTTP Component", () => {
it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />);
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: 'meshtastic.local' } })
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"))
.toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
@@ -52,10 +55,10 @@ describe("HTTP Component", () => {
const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument();
fireEvent.click(switchInput)
fireEvent.click(switchInput);
expect(screen.getByText("https://")).toBeInTheDocument();
fireEvent.click(switchInput)
fireEvent.click(switchInput);
expect(switchInput).not.toBeChecked();
expect(screen.getByText("http://")).toBeInTheDocument();
});
@@ -89,8 +92,7 @@ describe("HTTP Component", () => {
expect(MeshDevice).toBeCalled();
});
} catch (e) {
console.error(e)
console.error(e);
}
});
});
+27 -12
View File
@@ -11,7 +11,7 @@ import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react";
import { useForm, useController } from "react-hook-form";
import { useController, useForm } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
@@ -20,7 +20,10 @@ interface FormData {
tls: boolean;
}
export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgress }: TabElementProps) => {
export const HTTP = (
{ closeDialog, setConnectionInProgress, connectionInProgress }:
TabElementProps,
) => {
const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore();
@@ -30,8 +33,8 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
const { control, handleSubmit, register } = useForm<FormData>({
defaultValues: {
ip: ["client.meshtastic.org", "localhost"].includes(
globalThis.location.hostname,
)
globalThis.location.hostname,
)
? "meshtastic.local"
: globalThis.location.host,
tls: isURLHTTPS ? true : false,
@@ -42,7 +45,9 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
field: { value: tlsValue, onChange: setTLS },
} = useController({ name: "tls", control });
const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null);
const [connectionError, setConnectionError] = useState<
{ host: string; secure: boolean } | null
>(null);
const onSubmit = handleSubmit(async (data) => {
setConnectionInProgress(true);
@@ -91,21 +96,31 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
{connectionError && (
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
<div className="flex gap-2 items-start">
<AlertTriangle className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600" size={20} />
<AlertTriangle
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600"
size={20}
/>
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
Connection Failed
</p>
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
Could not connect to the device. {connectionError.secure && "If using HTTPS, you may need to accept a self-signed certificate first. "}
Could not connect to the device. {connectionError.secure &&
"If using HTTPS, you may need to accept a self-signed certificate first. "}
Please open{" "}
<Link
href={`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
href={`${
connectionError.secure ? "https" : "http"
}://${connectionError.host}`}
className="underline font-medium text-amber-800 dark:text-amber-800"
>
{`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
{`${
connectionError.secure ? "https" : "http"
}://${connectionError.host}`}
</Link>{" "}
in a new tab{connectionError.secure ? ", accept any TLS warnings if prompted, then try again" : ""}.{" "}
in a new tab{connectionError.secure
? ", accept any TLS warnings if prompted, then try again"
: ""}.{" "}
<Link
href="https://meshtastic.org/docs/software/web-client/#http"
className="underline font-medium text-amber-800 dark:text-amber-800"
@@ -120,10 +135,10 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
</div>
<Button
type="submit"
variant={"default"}
variant="default"
>
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
</Button>
</form>
);
};
};
@@ -10,10 +10,12 @@ import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
export const Serial = (
{ setConnectionInProgress, closeDialog }: TabElementProps,
) => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore();
const messageStore = useMessageStore()
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore();
const updateSerialPortList = useCallback(async () => {
@@ -58,8 +60,9 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
await onConnect(port);
}}
>
{`# ${index} - ${usbVendorId ?? "UNK"} - ${usbProductId ?? "UNK"
}`}
{`# ${index} - ${usbVendorId ?? "UNK"} - ${
usbProductId ?? "UNK"
}`}
</Button>
);
})}
@@ -23,7 +23,10 @@ import {
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import { useDevice } from "@core/stores/deviceStore.ts";
import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts";
import {
MessageType,
useMessageStore,
} from "../../../core/stores/messageStore/index.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
export interface NodeDetailProps {
@@ -51,10 +54,12 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={shortName} size="sm" />
<div onFocusCapture={(e) => {
// Required to prevent DM tooltip auto-appearing on creation
e.stopPropagation();
}}>
<div
onFocusCapture={(e) => {
// Required to prevent DM tooltip auto-appearing on creation
e.stopPropagation();
}}
>
{node.user?.publicKey && node.user?.publicKey.length > 0
? (
<LockIcon
@@ -177,7 +182,9 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{!!node.deviceMetrics?.airUtilTx && (
<div className="grow">
<div>Airtime Util</div>
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
<Mono className="text-gray-500">
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
</Mono>
</div>
)}
</div>
@@ -19,7 +19,7 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const scrollContainerRef = useRef<HTMLUListElement>(null);
const userScrolledUpRef = useRef(false);
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior });
});
@@ -28,10 +28,12 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
const isScrolledToBottom =
scrollContainer.scrollHeight - scrollContainer.scrollTop -
scrollContainer.clientHeight <= 10;
if (isScrolledToBottom || !userScrolledUpRef.current) {
scrollToBottom('smooth');
scrollToBottom("smooth");
}
}, [messages, scrollToBottom]);
@@ -39,12 +41,16 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const scrollContainer = scrollContainerRef.current;
const handleScroll = () => {
if (!scrollContainer) return;
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
const isAtBottom =
scrollContainer.scrollHeight - scrollContainer.scrollTop -
scrollContainer.clientHeight <= 10;
userScrolledUpRef.current = !isAtBottom;
};
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
scrollContainer?.addEventListener("scroll", handleScroll, {
passive: true,
});
return () => {
scrollContainer?.removeEventListener('scroll', handleScroll);
scrollContainer?.removeEventListener("scroll", handleScroll);
};
}, []);
@@ -74,4 +80,4 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
<div ref={messagesEndRef} className="h-px" />
</ul>
);
};
};
@@ -6,7 +6,7 @@ import {
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { cn } from "@core/utils/cn.ts";
import { SmilePlus, Reply } from "lucide-react";
import { Reply, SmilePlus } from "lucide-react";
interface MessageActionsMenuProps {
onAddReaction?: () => void;
@@ -15,7 +15,7 @@ interface MessageActionsMenuProps {
export const MessageActionsMenu = ({
onAddReaction,
onReply
onReply,
}: MessageActionsMenuProps) => {
const hoverIconBarClass = cn(
"absolute top-2 right-2",
@@ -25,7 +25,7 @@ export const MessageActionsMenu = ({
"rounded-md shadow-sm p-1",
"opacity-0 group-hover:opacity-100",
"transition-opacity duration-100 ease-in-out",
"z-10"
"z-10",
);
const hoverIconButtonClass = cn(
@@ -33,13 +33,16 @@ export const MessageActionsMenu = ({
"text-gray-500 dark:text-gray-400",
"hover:text-gray-700 dark:hover:text-gray-300",
"hover:bg-gray-100 dark:hover:bg-zinc-700",
"cursor-pointer"
"cursor-pointer",
);
const iconSizeClass = "size-4";
return (
<div className={cn(hoverIconBarClass)} onClick={(e) => e.stopPropagation()}>
<div
className={cn(hoverIconBarClass)}
onClick={(e) => e.stopPropagation()}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@@ -47,7 +50,7 @@ export const MessageActionsMenu = ({
type="button"
aria-label="Add Reaction"
onClick={(e) => {
e.stopPropagation()
e.stopPropagation();
if (onAddReaction) {
onAddReaction();
}
@@ -69,7 +72,7 @@ export const MessageActionsMenu = ({
type="button"
aria-label="Reply"
onClick={(e) => {
e.stopPropagation()
e.stopPropagation();
if (onReply) {
onReply();
}
@@ -85,7 +88,6 @@ export const MessageActionsMenu = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
};
@@ -1,18 +1,34 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { MessageInput, MessageInputProps } from './MessageInput.tsx';
import { Types } from '@meshtastic/core';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, MessageInputProps } from "./MessageInput.tsx";
import { Types } from "@meshtastic/core";
vi.mock('@components/UI/Button.tsx', () => ({
Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => (
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}>
vi.mock("@components/UI/Button.tsx", () => ({
Button: vi.fn((
{ type, className, children, onClick, onSubmit, ...rest },
) => (
<button
type={type}
className={className}
onClick={onClick}
onSubmit={onSubmit}
{...rest}
>
{children}
</button>
)),
}));
vi.mock('@components/UI/Input.tsx', () => ({
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => (
vi.mock("@components/UI/Input.tsx", () => ({
Input: vi.fn((
{ autoFocus, minLength, name, placeholder, value, onChange },
) => (
<input
autoFocus={autoFocus}
minLength={minLength}
@@ -29,28 +45,28 @@ const mockSetDraft = vi.fn();
const mockGetDraft = vi.fn();
const mockClearDraft = vi.fn();
vi.mock('@core/stores/messageStore', () => ({
vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
})),
MessageState: {
Ack: 'ack',
Waiting: 'waiting',
Failed: 'failed',
Ack: "ack",
Waiting: "waiting",
Failed: "failed",
},
MessageType: {
Direct: 'direct',
Broadcast: 'broadcast',
Direct: "direct",
Broadcast: "broadcast",
},
}));
vi.mock('lucide-react', () => ({
vi.mock("lucide-react", () => ({
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
}));
describe('MessageInput', () => {
describe("MessageInput", () => {
const mockOnSend = vi.fn();
const defaultProps: MessageInputProps = {
onSend: mockOnSend,
@@ -61,56 +77,62 @@ describe('MessageInput', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetDraft.mockReturnValue('');
mockGetDraft.mockReturnValue("");
});
const renderComponent = (props: Partial<MessageInputProps> = {}) => {
render(<MessageInput {...defaultProps} {...props} />);
};
it('should render the input field, byte counter, and send button', () => {
it("should render the input field, byte counter, and send button", () => {
renderComponent();
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
expect(screen.getByTestId('byte-counter')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter Message")).toBeInTheDocument();
expect(screen.getByTestId("byte-counter")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
});
it('should initialize with the draft from the store', () => {
const initialDraft = 'Existing draft message';
it("should initialize with the draft from the store", () => {
const initialDraft = "Existing draft message";
mockGetDraft.mockImplementation((key) => {
return key === defaultProps.to ? initialDraft : '';
return key === defaultProps.to ? initialDraft : "";
});
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
const inputElement = screen.getByPlaceholderText(
"Enter Message",
) as HTMLInputElement;
expect(inputElement.value).toBe(initialDraft);
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size;
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
});
it('should update input value, byte counter, and call setDraft on change within limits', () => {
it("should update input value, byte counter, and call setDraft on change within limits", () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const testMessage = 'Hello there!';
const inputElement = screen.getByPlaceholderText("Enter Message");
const testMessage = "Hello there!";
const expectedBytes = new Blob([testMessage]).size;
fireEvent.change(inputElement, { target: { value: testMessage } });
expect((inputElement as HTMLInputElement).value).toBe(testMessage);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
expect(mockSetDraft).toHaveBeenCalledTimes(1);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
});
it('should NOT update input value or call setDraft if maxBytes is exceeded', () => {
it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByPlaceholderText('Enter Message');
const initialValue = '12345';
const excessiveValue = '123456';
const inputElement = screen.getByPlaceholderText("Enter Message");
const initialValue = "12345";
const excessiveValue = "123456";
fireEvent.change(inputElement, { target: { value: initialValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
@@ -120,15 +142,17 @@ describe('MessageInput', () => {
fireEvent.change(inputElement, { target: { value: excessiveValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`);
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${smallMaxBytes}/${smallMaxBytes}`,
);
expect(mockSetDraft).not.toHaveBeenCalled();
});
it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => {
it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const testMessage = 'Send this message';
const inputElement = screen.getByPlaceholderText("Enter Message");
const formElement = screen.getByRole("form");
const testMessage = "Send this message";
fireEvent.change(inputElement, { target: { value: testMessage } });
fireEvent.submit(formElement);
@@ -136,21 +160,25 @@ describe('MessageInput', () => {
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(testMessage);
expect((inputElement as HTMLInputElement).value).toBe('');
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`);
expect((inputElement as HTMLInputElement).value).toBe("");
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`0/${defaultProps.maxBytes}`,
);
expect(mockClearDraft).toHaveBeenCalledTimes(1);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
});
it('should trim whitespace before calling onSend', async () => {
it("should trim whitespace before calling onSend", async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const testMessageWithWhitespace = ' Trim me! ';
const expectedTrimmedMessage = 'Trim me!';
const inputElement = screen.getByPlaceholderText("Enter Message");
const formElement = screen.getByRole("form");
const testMessageWithWhitespace = " Trim me! ";
const expectedTrimmedMessage = "Trim me!";
fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } });
fireEvent.change(inputElement, {
target: { value: testMessageWithWhitespace },
});
fireEvent.submit(formElement);
await waitFor(() => {
@@ -160,28 +188,28 @@ describe('MessageInput', () => {
});
});
it('should not call onSend or clearDraft if input is empty on submit', async () => {
it("should not call onSend or clearDraft if input is empty on submit", async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const inputElement = screen.getByPlaceholderText("Enter Message");
const formElement = screen.getByRole("form");
expect((inputElement as HTMLInputElement).value).toBe('');
expect((inputElement as HTMLInputElement).value).toBe("");
fireEvent.submit(formElement);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
});
it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => {
it("should not call onSend or clearDraft if input contains only whitespace on submit", async () => {
renderComponent();
const inputElement = screen.getByTestId('message-input-field');
const formElement = screen.getByRole('form');
const whitespaceMessage = ' \t ';
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
const whitespaceMessage = " \t ";
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
@@ -189,7 +217,7 @@ describe('MessageInput', () => {
fireEvent.submit(formElement);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(mockOnSend).not.toHaveBeenCalled();
@@ -198,18 +226,22 @@ describe('MessageInput', () => {
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
});
it('should work with broadcast destination for drafts', () => {
const broadcastDest: Types.Destination = 'broadcast';
mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : '');
it("should work with broadcast destination for drafts", () => {
const broadcastDest: Types.Destination = "broadcast";
mockGetDraft.mockImplementation((key) =>
key === broadcastDest ? "Broadcast draft" : ""
);
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft');
expect(
(screen.getByPlaceholderText("Enter Message") as HTMLInputElement).value,
).toBe("Broadcast draft");
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const newMessage = 'New broadcast msg';
const inputElement = screen.getByPlaceholderText("Enter Message");
const formElement = screen.getByRole("form");
const newMessage = "New broadcast msg";
fireEvent.change(inputElement, { target: { value: newMessage } });
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
@@ -219,4 +251,4 @@ describe('MessageInput', () => {
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
});
});
});
@@ -22,7 +22,9 @@ export const MessageInput = ({
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft));
const [messageBytes, setMessageBytes] = useState(() =>
calculateBytes(initialDraft)
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
@@ -63,7 +65,10 @@ export const MessageInput = ({
/>
</label>
<label data-testid="byte-counter" className="flex items-center w-20 p-1 text-sm place-content-end">
<label
data-testid="byte-counter"
className="flex items-center w-20 p-1 text-sm place-content-end"
>
{messageBytes}/{maxBytes}
</label>
@@ -77,4 +82,4 @@ export const MessageInput = ({
</form>
</div>
);
};
};
@@ -11,7 +11,10 @@ import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react";
import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts";
import {
MessageState,
useMessageStore,
} from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/js";
import { Message } from "@core/stores/messageStore/types.ts";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
@@ -24,17 +27,42 @@ interface MessageStatusInfo {
}
const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = {
[MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" },
[MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" },
[MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" },
[MessageState.Ack]: {
displayText: "Message delivered",
icon: CheckCircle2,
ariaLabel: "Message delivered",
iconClassName: "text-green-500",
},
[MessageState.Waiting]: {
displayText: "Waiting for delivery",
icon: CircleEllipsis,
ariaLabel: "Sending message",
iconClassName: "text-slate-400",
},
[MessageState.Failed]: {
displayText: "Delivery failed",
icon: AlertCircle,
ariaLabel: "Message delivery failed",
iconClassName: "text-red-500 dark:text-red-400",
},
};
const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" };
const UNKNOWN_STATUS: MessageStatusInfo = {
displayText: "Unknown state",
icon: AlertCircle,
ariaLabel: "Message status unknown",
iconClassName: "text-red-500 dark:text-red-400",
};
const getMessageStatusInfo = (state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS;
const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => (
const StatusTooltip = (
{ statusInfo, children }: {
statusInfo: MessageStatusInfo;
children: ReactNode;
},
) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
@@ -52,18 +80,17 @@ interface MessageItemProps {
export const MessageItem = ({ message }: MessageItemProps) => {
const { getNode } = useDevice();
const { getMyNodeNum } = useMessageStore()
const { getMyNodeNum } = useMessageStore();
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]);
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
const { displayName, shortName } = useMemo(() => {
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0');
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
const last4 = userIdHex.slice(-4);
const fallbackName = `Meshtastic ${last4}`
const fallbackName = `Meshtastic ${last4}`;
const longName = messageUser?.user?.longName;
const derivedShortName = messageUser?.user?.shortName || fallbackName;
const derivedDisplayName = longName || derivedShortName;
@@ -73,22 +100,35 @@ export const MessageItem = ({ message }: MessageItemProps) => {
const messageStatusInfo = getMessageStatusInfo(message.state);
const StatusIconComponent = messageStatusInfo.icon;
const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]);
const locale = 'en-US'; // TODO: Make dynamic via props or context
const messageDate = useMemo(
() => message.date ? new Date(message.date) : null,
[message.date],
);
const locale = "en-US"; // TODO: Make dynamic via props or context
const formattedTime = useMemo(() =>
messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '',
[messageDate, locale]);
const formattedTime = useMemo(
() =>
messageDate?.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
hour12: true,
}) ?? "",
[messageDate, locale],
);
const fullDateTime = useMemo(() =>
messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '',
[messageDate, locale]);
const fullDateTime = useMemo(
() =>
messageDate?.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
}) ?? "",
[messageDate, locale],
);
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
const messageItemWrapperClass = cn(
"group w-full py-2 relative list-none",
"rounded-md",
@@ -97,7 +137,6 @@ export const MessageItem = ({ message }: MessageItemProps) => {
);
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
return (
<li className={messageItemWrapperClass}>
<div className="grid grid-cols-[auto_1fr] gap-x-2">
@@ -109,7 +148,10 @@ export const MessageItem = ({ message }: MessageItemProps) => {
{displayName}
</span>
{messageDate && (
<time dateTime={messageDate.toISOString()} className={dateTextStyle}>
<time
dateTime={messageDate.toISOString()}
className={dateTextStyle}
>
<span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDateTime}</span>
</time>
@@ -118,7 +160,10 @@ export const MessageItem = ({ message }: MessageItemProps) => {
<StatusTooltip statusInfo={messageStatusInfo}>
<span aria-label={messageStatusInfo.ariaLabel} role="img">
<StatusIconComponent
className={cn("size-4 shrink-0", messageStatusInfo.iconClassName)}
className={cn(
"size-4 shrink-0",
messageStatusInfo.iconClassName,
)}
aria-hidden="true"
/>
</span>
@@ -134,9 +179,11 @@ export const MessageItem = ({ message }: MessageItemProps) => {
</div>
</div>
{/* Actions Menu Placeholder */}
{/* <div className="absolute top-1 right-1">
{
/* <div className="absolute top-1 right-1">
<MessageActionsMenu onReply={() => console.log("Reply")} />
</div> */}
</div> */
}
</li>
);
};
};
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -34,11 +34,11 @@ describe("TraceRoute", () => {
it("renders the route to destination with SNR values", () => {
render(
<TraceRoute
from={{ user: { longName: "Source Node" } } as any}
to={{ user: { longName: "Destination Node" } } as any}
from={{ user: { longName: "Source Node" } }}
to={{ user: { longName: "Destination Node" } }}
route={[1, 2]}
snrTowards={[10, 20, 30]}
/>
/>,
);
expect(screen.getAllByText("Source Node")).toHaveLength(1);
@@ -56,13 +56,13 @@ describe("TraceRoute", () => {
it("renders the route back when provided", () => {
render(
<TraceRoute
from={{ user: { longName: "Source Node" } } as any}
to={{ user: { longName: "Destination Node" } } as any}
from={{ user: { longName: "Source Node" } }}
to={{ user: { longName: "Destination Node" } }}
route={[1]}
snrTowards={[15, 25]}
routeBack={[3]}
snrBack={[35, 45]}
/>
/>,
);
expect(screen.getByText("Route back:")).toBeInTheDocument();
@@ -79,16 +79,15 @@ describe("TraceRoute", () => {
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
expect(screen.getByText("↓ 25dB")).toBeInTheDocument();
});
it("renders '??' for missing SNR values", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as any}
to={{ user: { longName: "Dest" } } as any}
from={{ user: { longName: "Source" } }}
to={{ user: { longName: "Dest" } }}
route={[1]}
/>
/>,
);
expect(screen.getByText("Node A")).toBeInTheDocument();
@@ -102,11 +101,11 @@ describe("TraceRoute", () => {
to={{ user: { longName: "Dest" } } as unknown}
route={[99]}
snrTowards={[5, 15]}
/>
/>,
);
expect(screen.getByText(/^!63$/)).toBeInTheDocument();
expect(screen.getByText("↓ 5dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
});
});
});
@@ -19,17 +19,24 @@ interface RoutePathProps {
snr?: number[];
}
const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => {
const RoutePath = (
{ title, startNode, endNode, path, snr }: RoutePathProps,
) => {
const { getNode } = useDevice();
return (
<span id={title} className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
<span
id={title}
className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900"
>
<p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p>
<p>↓ {snr?.[0] ?? "??"}dB</p>
{path.map((hop, i) => (
<span key={getNode(hop)?.num ?? hop}>
<p>{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p>
<p>
{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}
</p>
<p>↓ {snr?.[i + 1] ?? "??"}dB</p>
</span>
))}