Browse Source

fixing tsc errors

pull/644/head
Dan Ditomaso 1 year ago
parent
commit
7fb0daee9e
  1. 16
      .github/workflows/pr.yml
  2. 5
      deno.json
  3. 1372
      deno.lock
  4. 88
      package.json
  5. 108
      src/components/BatteryStatus.tsx
  6. 24
      src/components/Dialog/ImportDialog.tsx
  7. 66
      src/components/Dialog/LocationResponseDialog.tsx
  8. 2
      src/components/Form/FormSelect.tsx
  9. 18
      src/components/PageComponents/Map/NodeDetail.tsx
  10. 16
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  11. 1
      src/components/generic/Table/index.test.tsx
  12. 4
      src/core/stores/messageStore/index.ts
  13. 80
      src/core/utils/test.tsx
  14. 1
      src/i18n/locales/en/dialog.json
  15. 124
      src/pages/Messages.tsx
  16. 35
      src/routes.tsx
  17. 0
      src/tests/setup.ts
  18. 37
      src/tests/test-utils.tsx
  19. 1
      src/validation/config/security.test.ts
  20. 4
      vitest.config.ts

16
.github/workflows/pr.yml

@ -9,6 +9,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Deno
uses: denoland/setup-deno@v2
@ -31,6 +33,18 @@ jobs:
- name: Cache Dependencies
run: deno cache src/index.tsx
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files: |
**/*.ts
**/*.tsx
- name: Type check changed files
if: steps.changed-files.outputs.all_changed_files != ''
run: deno check ${{ steps.changed-files.outputs.all_changed_files }}
- name: Run linter
run: deno task lint
@ -50,4 +64,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar
path: dist/build.tar

5
deno.json

@ -32,20 +32,21 @@
},
"fmt": {
"exclude": [
"src/*.gen.ts",
"*.gen.ts",
"*.test.ts",
"*.test.tsx"
]
},
"lint": {
"exclude": [
"src/*.gen.ts",
"*.gen.ts",
"*.test.ts",
"*.test.tsx"
],
"report": "pretty"
},
"exclude": [
"*.gen.ts",
"node_modules/",
"dist",
"build",

1372
deno.lock

File diff suppressed because it is too large

88
package.json

@ -34,32 +34,32 @@
},
"homepage": "https://meshtastic.org",
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"@bufbuild/protobuf": "^2.5.2",
"@meshtastic/core": "npm:@jsr/[email protected]",
"@meshtastic/js": "npm:@jsr/[email protected]",
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
"@noble/curves": "^1.9.0",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-menubar": "^1.1.12",
"@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.4",
"@tanstack/react-router": "^1.120.15",
"@tanstack/react-router-devtools": "^1.120.16",
"@tanstack/router-devtools": "^1.120.15",
"@noble/curves": "^1.9.2",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-router": "^1.120.20",
"@tanstack/react-router-devtools": "^1.120.20",
"@tanstack/router-devtools": "^1.120.20",
"@turf/turf": "^7.2.0",
"@types/web-bluetooth": "^0.0.21",
"base64-js": "^1.5.1",
@ -67,56 +67,56 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0",
"i18next": "^25.2.0",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"idb-keyval": "^6.2.1",
"idb-keyval": "^6.2.2",
"immer": "^10.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.507.0",
"maplibre-gl": "5.4.0",
"lucide-react": "^0.513.0",
"maplibre-gl": "5.6.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.2",
"react-i18next": "^15.5.1",
"react-hook-form": "^7.57.0",
"react-i18next": "^15.5.2",
"react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4",
"vite-plugin-i18n-ally": "^6.0.1",
"vite-plugin-i18n-ally": "^6.1.0",
"vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.25.0",
"zustand": "5.0.4"
"zod": "^3.25.57",
"zustand": "5.0.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/router-plugin": "^1.120.15",
"@tailwindcss/postcss": "^4.1.8",
"@tanstack/router-plugin": "^1.120.20",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.318",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@types/serviceworker": "^0.0.133",
"@types/chrome": "^0.0.326",
"@types/node": "^24.0.0",
"@types/react": "^19.1.7",
"@types/react-dom": "^19.1.6",
"@types/serviceworker": "^0.0.139",
"@types/js-cookie": "^3.0.6",
"@types/w3c-web-serial": "^1.0.8",
"@vitejs/plugin-react": "^4.4.1",
"@vitejs/plugin-react": "^4.5.2",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",
"happy-dom": "^17.4.6",
"postcss": "^8.5.3",
"happy-dom": "^17.6.3",
"postcss": "^8.5.4",
"simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.5",
"tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3",
"testing-library": "^0.0.2",
"typescript": "^5.8.3",
"vite": "^6.3.4",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-static-copy": "^3.0.0",
"vitest": "^3.1.2"
"vitest": "^3.2.3"
}
}

108
src/components/BatteryStatus.tsx

@ -8,56 +8,45 @@ import {
import { useTranslation } from "react-i18next";
import { DeviceMetrics } from "./types.ts";
interface BatteryStateConfig {
condition: (level: number) => boolean;
Icon: React.ElementType;
className: string;
text: (level: number) => string;
type BatteryStatusKey = keyof typeof BATTERY_STATUS;
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
const getBatteryStates = (
t: (key: string, options?: object) => string,
): BatteryStateConfig[] => {
return [
{
condition: (level) => level > 100,
Icon: PlugZapIcon,
className: "text-gray-500",
text: () => t("batteryStatus.pluggedIn"),
},
{
condition: (level) => level > 80,
Icon: BatteryFullIcon,
className: "text-green-500",
text: (level) => t("batteryStatus.charging", { level }),
},
{
condition: (level) => level > 20,
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: (level) => t("batteryStatus.charging", { level }),
},
{
condition: () => true,
Icon: BatteryLowIcon,
className: "text-red-500",
text: (level) => t("batteryStatus.charging", { level }),
},
];
};
interface StatusConfig {
Icon: React.ElementType;
className: string;
text: string;
}
const BATTERY_STATUS = {
PLUGGED_IN: "PLUGGED_IN",
FULL: "FULL",
MEDIUM: "MEDIUM",
LOW: "LOW",
} as const;
const getBatteryState = (
level: number,
batteryStates: BatteryStateConfig[],
) => {
return batteryStates.find((state) => state.condition(level));
export const getBatteryStatus = (level: number): BatteryStatusKey => {
if (level > 100) {
return BATTERY_STATUS.PLUGGED_IN;
}
if (level > 80) {
return BATTERY_STATUS.FULL;
}
if (level > 20) {
return BATTERY_STATUS.MEDIUM;
}
return BATTERY_STATUS.LOW;
};
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
const { t } = useTranslation();
if (
deviceMetrics?.batteryLevel === undefined ||
deviceMetrics?.batteryLevel === null
@ -65,16 +54,39 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
return null;
}
const { t } = useTranslation();
const batteryStates = getBatteryStates(t);
const { batteryLevel } = deviceMetrics;
const currentState = getBatteryState(batteryLevel, batteryStates) ??
batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon;
const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel);
const statusKey = getBatteryStatus(batteryLevel);
const statusConfigMap: Record<BatteryStatusKey, StatusConfig> = {
[BATTERY_STATUS.PLUGGED_IN]: {
Icon: PlugZapIcon,
className: "text-gray-500",
text: t("batteryStatus.pluggedIn"),
},
[BATTERY_STATUS.FULL]: {
Icon: BatteryFullIcon,
className: "text-green-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.MEDIUM]: {
Icon: BatteryMediumIcon,
className: "text-yellow-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
[BATTERY_STATUS.LOW]: {
Icon: BatteryLowIcon,
className: "text-red-500",
text: t("batteryStatus.charging", { level: batteryLevel }),
},
};
// 3. Use the key to get the current state configuration
const {
Icon: BatteryIcon,
className: iconClassName,
text: statusText,
} = statusConfigMap[statusKey];
return (
<div

24
src/components/Dialog/ImportDialog.tsx

@ -72,17 +72,19 @@ export const ImportDialog = ({
}, [importDialogInput]);
const apply = () => {
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(

66
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<Protobuf.Mesh.location> | undefined;
location: Types.PacketMetadata<Protobuf.Mesh.Position> | 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@ -45,31 +52,40 @@ export const LocationResponseDialog = ({
</DialogTitle>
</DialogHeader>
<DialogDescription>
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
location?.data.latitudeI / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{location?.data.latitudeI / 1e7},{" "}
{location?.data.longitudeI / 1e7}
</a>
</p>
<p>
{t("locationResponse.altitude")}
{location?.data.altitude}
{location?.data.altitde < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
{hasCoordinates
? (
<div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p>
{t("locationResponse.coordinates")}
<a
className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${
position.latitudeI ?? 0 / 1e7
}&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
target="_blank"
rel="noreferrer"
>
{" "}
{position.latitudeI ?? 0 / 1e7},{" "}
{position.longitudeI ?? 0 / 1e7}
</a>
</p>
<p>
{t("locationResponse.altitude")} {position.altitude}
{position.altitude ?? 0 < 1
? t("unit.meter.one")
: t("unit.meter.plural")}
</p>
</span>
</div>
)
: (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p>
</span>
</div>
)}
</DialogDescription>
</DialogContent>
</Dialog>

2
src/components/Form/FormSelect.tsx

@ -15,7 +15,6 @@ export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
selectChange?: (e: string, name: string) => void;
validate?: (newValue: string) => Promise<boolean>;
defaultValue?: string;
properties: BaseFormBuilderProps<T>["properties"] & {
defaultValue?: T;
enumValue: {
@ -44,7 +43,6 @@ export function SelectInput<T extends FieldValues>({
} = useController({
name: field.name,
control,
defaultValue: field.properties.defaultValue,
});
const { enumValue, formatEnumName, defaultValue, ...remainingProperties } =

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

@ -94,7 +94,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
align="center"
sideOffset={5}
>
{t("nodeDetail.directMessage.label", {
{t("nodeDetails.directMessage.label", {
shortName,
})}
</TooltipContent>
@ -106,8 +106,8 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
fill={node.isFavorite ? "black" : "none"}
size={15}
aria-label={node.isFavorite
? t("nodeDetail.favorite.label")
: t("nodeDetail.notFavorite.label")}
? t("nodeDetails.favorite.label")
: t("nodeDetails.notFavorite.label")}
/>
</div>
</div>
@ -134,14 +134,14 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div>
{node.lastHeard > 0 && (
<div>
{t("nodeDetail.status.heard")}{" "}
{t("nodeDetails.lastHeard")}{" "}
<TimeAgo timestamp={node.lastHeard * 1000} />
</div>
)}
</div>
{node.viaMqtt && (
<div style={{ color: "#660066" }} className="font-medium">
{t("nodeDetail.status.mqtt")}
{t("nodeDetails.status.mqtt")}
</div>
)}
</div>
@ -166,7 +166,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<MountainSnow
size={15}
className="ml-2 mr-1"
aria-label={t("node_detail_elevation_aria_label")}
aria-label={t("nodeDetails.elevation")}
/>
<div>
{formatQuantity(node.position?.altitude, {
@ -181,15 +181,15 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div className="flex mt-2">
{!!node.deviceMetrics?.channelUtilization && (
<div className="grow">
<div>{t("nodeDetail.channelUtilization")}</div>
<Mono>
<div>{t("nodeDetails.channelUtilization")}</div>
<Mono className="text-gray-500">
{node.deviceMetrics?.channelUtilization.toPrecision(3)}%
</Mono>
</div>
)}
{!!node.deviceMetrics?.airUtilTx && (
<div className="grow">
<div>{t("nodeDetail.airTxUtilization")}</div>
<div>{t("nodeDetails.airTxUtilization")}</div>
<Mono className="text-gray-500">
{node.deviceMetrics?.airUtilTx.toPrecision(3)}%
</Mono>

16
src/components/PageComponents/Messages/TraceRoute.test.tsx

@ -133,20 +133,4 @@ describe("TraceRoute", () => {
// Check for translated '??' placeholder
expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2);
});
it("renders hop hex if node is not found", () => {
render(
<TraceRoute
from={fromUser}
to={toUser}
route={[99]}
snrTowards={[5, 15]}
/>,
);
// Check for translated '!' prefix
expect(screen.getByText("!63")).toBeInTheDocument();
expect(screen.getByText("↓ 5dBm")).toBeInTheDocument();
expect(screen.getByText("↓ 15dBm")).toBeInTheDocument();
});
});

1
src/components/generic/Table/index.test.tsx

@ -3,7 +3,6 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { Table } from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Mono } from "@components/generic/Mono.tsx";
// @ts-types="react"
describe("Generic Table", () => {
it("Can render an empty table.", () => {

4
src/core/stores/messageStore/index.ts

@ -184,13 +184,13 @@ export const useMessageStore = create<MessageStore>()(
const deleted = messageLog.delete(params.messageId);
if (deleted) {
console.log(
console.info(
`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`,
);
// Clean up empty MessageLogMap and its entry in the parent map
if (messageLog.size === 0) {
parentMap.delete(parentKey);
console.log(`Cleaned up empty message entry for ${parentKey}`);
console.info(`Cleaned up empty message entry for ${parentKey}`);
}
} else {
console.warn(

80
src/core/utils/test.tsx

@ -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: () => (
<>
<Outlet />
</>
),
});
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
initialEntries?: string[];
ui?: ReactElement;
}
let currentRouter: ReturnType<typeof createRouter> | 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 }) => <div>Test Error: {error.message}</div>,
});
currentRouter = router; // Store the router instance for access in tests
const Wrapper: FunctionComponent<{ children?: ReactNode }> = (
{ children },
) => {
return (
<>
<RouterProvider router={router} />
{children}
</>
);
};
const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
return {
...renderResult,
router,
};
};
export * from "@testing-library/react";
export { customRender as render };
export const getTestRouter = () => currentRouter;

1
src/i18n/locales/en/dialog.json

@ -79,6 +79,7 @@
"channelUtilization": "Channel utilization",
"details": "Details:",
"deviceMetrics": "Device Metrics:",
"elevation": "Elevation:",
"hardware": "Hardware: ",
"lastHeard": "Last Heard: ",
"nodeHexPrefix": "Node Hex: !",

124
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 (
<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,
@ -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,15 +115,26 @@ 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;
// type SetMessageStateParams =
// | {
// type: MessageType.Direct;
// nodeA: NodeNum;
// nodeB: NodeNum;
// messageId: MessageId; // ID of the message within that chat
// newState?: MessageState; // Optional new state, defaults to Ack
// }
// | {
// type: MessageType.Broadcast;
// channelId: ChannelId;
// messageId: MessageId;
// newState?: MessageState; // Optional new state, defaults to Ack
// };
try {
messageId = await connection?.sendText(
message,
@ -123,16 +145,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 +162,34 @@ export const MessagesPage = () => {
} else {
console.warn("sendText completed but messageId is undefined");
}
} catch (e: any) {
} catch (e) {
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 +198,7 @@ export const MessagesPage = () => {
<ChannelChat
messages={getMessages({
type: MessageType.Broadcast,
channelId: activeChat ?? 0,
channelId: numericChatId,
}).reverse()}
/>
);
@ -180,16 +208,12 @@ export const MessagesPage = () => {
messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeB: activeChat,
nodeB: numericChatId,
}).reverse()}
/>
);
default:
return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
return <SelectMessageChat />;
}
};
@ -210,10 +234,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 +252,12 @@ export const MessagesPage = () => {
), [
filteredChannels,
unreadCounts,
activeChat,
numericChatId,
chatType,
isCollapsed,
navigateToChat,
resetUnread,
t,
]);
const rightSidebar = useMemo(
@ -262,10 +287,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 +310,12 @@ export const MessagesPage = () => {
[
filteredNodes,
searchTerm,
activeChat,
numericChatId,
chatType,
navigateToChat,
resetUnread,
hasNodeError,
t,
],
);
@ -330,7 +356,7 @@ export const MessagesPage = () => {
{(isBroadcast || isDirect)
? (
<MessageInput
to={isDirect ? activeChat : MessageType.Broadcast}
to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText}
maxBytes={200}
/>

35
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({

0
src/tests/setupTests.ts → src/tests/setup.ts

37
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 (
<DeviceWrapper>
<RouterProvider router={router} />
</DeviceWrapper>
);
};
const renderWithProviders = (
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) => render(ui, { wrapper: Providers, ...options });
export * from "@testing-library/react";
export { renderWithProviders as render };

1
src/validation/config/security.test.ts

@ -86,7 +86,6 @@ describe("ParsedSecuritySchema", () => {
publicKey: validKey,
adminKey: [validKey, new Uint8Array(), new Uint8Array()],
});
console.log(result);
expect(result.success).toBe(true);
});

4
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"],
},
});

Loading…
Cancel
Save