Browse Source

completing tsc fixes

pull/649/head
Dan Ditomaso 1 year ago
parent
commit
ae926c1138
  1. 668
      deno.lock
  2. 27
      src/PageRouter.tsx
  3. 108
      src/components/BatteryStatus.tsx
  4. 6
      src/components/Dialog/ImportDialog.tsx
  5. 32
      src/components/Dialog/LocationResponseDialog.tsx
  6. 15
      src/components/PageComponents/Connect/BLE.tsx
  7. 18
      src/components/PageComponents/Messages/TraceRoute.test.tsx
  8. 80
      src/core/utils/test.tsx
  9. 1
      src/index.css
  10. 109
      src/pages/Messages.tsx
  11. 35
      src/routes.tsx
  12. 0
      src/tests/setup.ts
  13. 37
      src/tests/test-utils.tsx
  14. 4
      vitest.config.ts

668
deno.lock

File diff suppressed because it is too large

27
src/PageRouter.tsx

@ -1,27 +0,0 @@
import MapPage from "@app/pages/Map/index.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import ChannelsPage from "@pages/Channels.tsx";
import ConfigPage from "@pages/Config/index.tsx";
import MessagesPage from "@pages/Messages.tsx";
import NodesPage from "./pages/Nodes/index.tsx";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export const ErrorBoundaryWrapper = ({
children,
}: { children: React.ReactNode }) => (
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary>
);
export const PageRouter = () => {
const { activePage } = useDevice();
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
{activePage === "messages" && <MessagesPage />}
{activePage === "map" && <MapPage />}
{activePage === "config" && <ConfigPage />}
{activePage === "channels" && <ChannelsPage />}
{activePage === "nodes" && <NodesPage />}
</ErrorBoundary>
);
};

108
src/components/BatteryStatus.tsx

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

6
src/components/Dialog/ImportDialog.tsx

@ -72,7 +72,8 @@ export const ImportDialog = ({
}, [importDialogInput]); }, [importDialogInput]);
const apply = () => { const apply = () => {
channelSet?.settings.map((ch: unknown, index: number) => { channelSet?.settings.map(
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
connection?.setChannel( connection?.setChannel(
create(Protobuf.Channel.ChannelSchema, { create(Protobuf.Channel.ChannelSchema, {
index, index,
@ -82,7 +83,8 @@ export const ImportDialog = ({
settings: ch, settings: ch,
}), }),
); );
}); },
);
if (channelSet?.loraConfig) { if (channelSet?.loraConfig) {
connection?.setConfig( connection?.setConfig(

32
src/components/Dialog/LocationResponseDialog.tsx

@ -12,7 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface LocationResponseDialogProps { export interface LocationResponseDialogProps {
location: Types.PacketMetadata<Protobuf.Mesh.location> | undefined; location: Types.PacketMetadata<Protobuf.Mesh.Position> | undefined;
open: boolean; open: boolean;
onOpenChange: () => void; onOpenChange: () => void;
} }
@ -33,6 +33,13 @@ export const LocationResponseDialog = ({
? `${numberToHexUnpadded(from?.num).substring(0, 4)}` ? `${numberToHexUnpadded(from?.num).substring(0, 4)}`
: t("unknown.shortName")); : t("unknown.shortName"));
const position = location?.data;
const hasCoordinates = position &&
typeof position.latitudeI === "number" &&
typeof position.longitudeI === "number" &&
typeof position.altitude === "number";
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
@ -45,6 +52,8 @@ export const LocationResponseDialog = ({
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
{hasCoordinates
? (
<div className="ml-5 flex"> <div className="ml-5 flex">
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> <span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary">
<p> <p>
@ -52,24 +61,31 @@ export const LocationResponseDialog = ({
<a <a
className="text-blue-500 dark:text-blue-400" className="text-blue-500 dark:text-blue-400"
href={`https://www.openstreetmap.org/?mlat=${ href={`https://www.openstreetmap.org/?mlat=${
location?.data.latitudeI / 1e7 position.latitudeI ?? 0 / 1e7
}&mlon=${location?.data.longitudeI / 1e7}&layers=N`} }&mlon=${position.longitudeI ?? 0 / 1e7}&layers=N`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{location?.data.latitudeI / 1e7},{" "} {" "}
{location?.data.longitudeI / 1e7} {position.latitudeI ?? 0 / 1e7},{" "}
{position.longitudeI ?? 0 / 1e7}
</a> </a>
</p> </p>
<p> <p>
{t("locationResponse.altitude")} {t("locationResponse.altitude")} {position.altitude}
{location?.data.altitude} {(position.altitude ?? 0) < 1
{location?.data.altitde < 1
? t("unit.meter.one") ? t("unit.meter.one")
: t("unit.meter.plural")} : t("unit.meter.plural")}
</p> </p>
</span> </span>
</div> </div>
)
: (
// Optional: Show a message if coordinates are not available
<p className="text-textPrimary">
{t("locationResponse.noCoordinates")}
</p>
)}
</DialogDescription> </DialogDescription>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

15
src/components/PageComponents/Connect/BLE.tsx

@ -5,8 +5,9 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts"; import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { BleConnection, ServiceUuid } from "@meshtastic/js"; import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { BluetoothDevice } from "web-bluetooth"; import { MeshDevice } from "@meshtastic/core";
import type { BluetoothDevice } from "web-bluetooth";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -31,15 +32,13 @@ export const BLE = (
const onConnect = async (bleDevice: BluetoothDevice) => { const onConnect = async (bleDevice: BluetoothDevice) => {
const id = randId(); const id = randId();
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id); const device = addDevice(id);
const connection = new MeshDevice(transport, id);
connection.configure();
setSelectedDevice(id); setSelectedDevice(id);
const connection = new BleConnection(id);
await connection.connect({
device: bleDevice,
});
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection, messageStore); subscribeAll(device, connection, messageStore);
closeDialog(); closeDialog();
}; };
@ -72,7 +71,7 @@ export const BLE = (
onClick={async () => { onClick={async () => {
await navigator.bluetooth await navigator.bluetooth
.requestDevice({ .requestDevice({
filters: [{ services: [ServiceUuid] }], filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
}) })
.then((device) => { .then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id); const exists = bleDevices.findIndex((d) => d.id === device.id);

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

@ -70,7 +70,7 @@ describe("TraceRoute", () => {
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum); return mockNodes.get(nodeNum);
}, },
} as any); });
}); });
it("renders the route to destination with SNR values", () => { it("renders the route to destination with SNR values", () => {
@ -135,20 +135,4 @@ describe("TraceRoute", () => {
// Check for translated '??' placeholder // Check for translated '??' placeholder
expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2); 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();
});
}); });

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/index.css

@ -93,7 +93,6 @@ body {
} }
@layer base { @layer base {
*, *,
::after, ::after,
::before, ::before,

109
src/pages/Messages.tsx

@ -28,9 +28,19 @@ import { Input } from "@components/UI/Input.tsx";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router"; import { useNavigate, useParams } from "@tanstack/react-router";
import { messagesWithParamsRoute } from "@app/routes.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; 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 = () => { export const MessagesPage = () => {
const { const {
channels, channels,
@ -46,7 +56,9 @@ export const MessagesPage = () => {
getMessages, getMessages,
setMessageState, setMessageState,
} = useMessageStore(); } = useMessageStore();
const params = useParams({ from: "", shouldThrow: false });
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { isCollapsed } = useSidebar(); const { isCollapsed } = useSidebar();
@ -54,34 +66,33 @@ export const MessagesPage = () => {
const { t } = useTranslation(["messages", "channels", "ui"]); const { t } = useTranslation(["messages", "channels", "ui"]);
const deferredSearch = useDeferredValue(searchTerm); 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 ? MessageType.Direct
: params.type === "broadcast" : MessageType.Broadcast;
? MessageType.Broadcast const numericChatId = Number(chatId);
: undefined;
const activeChat = params.chatId ? Number(params.chatId) : undefined;
const allChannels = Array.from(channels.values()); const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter( const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, (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(() => { useEffect(() => {
if (!params.type && !params.chatId && filteredChannels.length > 0) { if (!type && !chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[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 filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase(); const lowerCaseSearchTerm = deferredSearch.toLowerCase();
@ -104,12 +115,8 @@ export const MessagesPage = () => {
}; };
const sendText = useCallback(async (message: string) => { const sendText = useCallback(async (message: string) => {
const isDirect = chatType === MessageType.Direct; const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const toValue = isDirect ? activeChat : MessageType.Broadcast; const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId;
const channelValue = isDirect
? Types.ChannelNumber.Primary
: activeChat ?? 0;
let messageId: number | undefined; let messageId: number | undefined;
@ -123,16 +130,16 @@ export const MessagesPage = () => {
if (messageId !== undefined) { if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) { if (chatType === MessageType.Broadcast) {
setMessageState({ setMessageState({
type: chatType, type: MessageType.Broadcast,
channelId: channelValue, channelId: channelValue,
messageId, messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
} else { } else {
setMessageState({ setMessageState({
type: chatType, type: MessageType.Direct,
nodeA: getMyNodeNum(), nodeA: getMyNodeNum(),
nodeB: activeChat, nodeB: numericChatId,
messageId, messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
@ -140,28 +147,34 @@ export const MessagesPage = () => {
} else { } else {
console.warn("sendText completed but messageId is undefined"); console.warn("sendText completed but messageId is undefined");
} }
} catch (e: any) { } catch (e: unknown) {
console.error("Failed to send message:", e); console.error("Failed to send message:", e);
const failedId = messageId ?? randId(); const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) { if (chatType === MessageType.Broadcast) {
setMessageState({ setMessageState({
type: chatType, type: MessageType.Broadcast,
channelId: channelValue, channelId: channelValue,
messageId: failedId, messageId: failedId,
newState: MessageState.Failed, newState: MessageState.Failed,
}); });
} else { // MessageType.Direct } else {
const failedId = messageId ?? randId();
setMessageState({ setMessageState({
type: chatType, type: MessageType.Direct,
nodeA: getMyNodeNum(), nodeA: getMyNodeNum(),
nodeB: activeChat, nodeB: numericChatId,
messageId: failedId, messageId: failedId,
newState: MessageState.Failed, newState: MessageState.Failed,
}); });
} }
} }
}, [activeChat, chatType, connection, getMyNodeNum, setMessageState]); }, [
numericChatId,
chatId,
chatType,
connection,
getMyNodeNum,
setMessageState,
]);
const renderChatContent = () => { const renderChatContent = () => {
switch (chatType) { switch (chatType) {
@ -170,7 +183,7 @@ export const MessagesPage = () => {
<ChannelChat <ChannelChat
messages={getMessages({ messages={getMessages({
type: MessageType.Broadcast, type: MessageType.Broadcast,
channelId: activeChat ?? 0, channelId: numericChatId,
}).reverse()} }).reverse()}
/> />
); );
@ -180,16 +193,12 @@ export const MessagesPage = () => {
messages={getMessages({ messages={getMessages({
type: MessageType.Direct, type: MessageType.Direct,
nodeA: getMyNodeNum(), nodeA: getMyNodeNum(),
nodeB: activeChat, nodeB: numericChatId,
}).reverse()} }).reverse()}
/> />
); );
default: default:
return ( return <SelectMessageChat />;
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
} }
}; };
@ -210,10 +219,10 @@ export const MessagesPage = () => {
index: channel.index, index: channel.index,
ns: "channels", ns: "channels",
}))} }))}
active={activeChat === channel.index && active={numericChatId === channel.index &&
chatType === MessageType.Broadcast} chatType === MessageType.Broadcast}
onClick={() => { onClick={() => {
navigateToChat(MessageType.Broadcast, channel.index); navigateToChat(MessageType.Broadcast, channel.index.toString());
resetUnread(channel.index); resetUnread(channel.index);
}} }}
> >
@ -228,11 +237,12 @@ export const MessagesPage = () => {
), [ ), [
filteredChannels, filteredChannels,
unreadCounts, unreadCounts,
activeChat, numericChatId,
chatType, chatType,
isCollapsed, isCollapsed,
navigateToChat, navigateToChat,
resetUnread, resetUnread,
t,
]); ]);
const rightSidebar = useMemo( const rightSidebar = useMemo(
@ -262,10 +272,10 @@ export const MessagesPage = () => {
label={node.user?.longName ?? label={node.user?.longName ??
t("unknown.shortName")} t("unknown.shortName")}
count={node.unreadCount > 0 ? node.unreadCount : undefined} count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={activeChat === node.num && active={numericChatId === node.num &&
chatType === MessageType.Direct} chatType === MessageType.Direct}
onClick={() => { onClick={() => {
navigateToChat(MessageType.Direct, node.num); navigateToChat(MessageType.Direct, node.num.toString());
resetUnread(node.num); resetUnread(node.num);
}} }}
> >
@ -285,11 +295,12 @@ export const MessagesPage = () => {
[ [
filteredNodes, filteredNodes,
searchTerm, searchTerm,
activeChat, numericChatId,
chatType, chatType,
navigateToChat, navigateToChat,
resetUnread, resetUnread,
hasNodeError, hasNodeError,
t,
], ],
); );
@ -330,7 +341,7 @@ export const MessagesPage = () => {
{(isBroadcast || isDirect) {(isBroadcast || isDirect)
? ( ? (
<MessageInput <MessageInput
to={isDirect ? activeChat : MessageType.Broadcast} to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText} onSend={sendText}
maxBytes={200} maxBytes={200}
/> />

35
src/routes.tsx

@ -4,10 +4,11 @@ import MessagesPage from "@pages/Messages.tsx";
import MapPage from "@pages/Map/index.tsx"; import MapPage from "@pages/Map/index.tsx";
import ConfigPage from "@pages/Config/index.tsx"; import ConfigPage from "@pages/Config/index.tsx";
import ChannelsPage from "@pages/Channels.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 { createRootRoute } from "@tanstack/react-router";
import { App } from "./App.tsx"; import { App } from "./App.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { z } from "zod";
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: App, component: App,
@ -27,12 +28,42 @@ const messagesRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/messages", path: "/messages",
component: MessagesPage, 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, getParentRoute: () => rootRoute,
path: "/messages/$type/$chatId", path: "/messages/$type/$chatId",
component: MessagesPage, 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({ 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 };

4
vitest.config.ts

@ -3,6 +3,8 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import { enableMapSet } from "immer"; import { enableMapSet } from "immer";
import process from "node:process";
enableMapSet(); enableMapSet();
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@ -25,6 +27,6 @@ export default defineConfig({
restoreMocks: true, restoreMocks: true,
root: path.resolve(process.cwd(), "./src"), root: path.resolve(process.cwd(), "./src"),
include: ["**/*.{test,spec}.{ts,tsx}"], include: ["**/*.{test,spec}.{ts,tsx}"],
setupFiles: ["./src/tests/setupTests.ts", "./src/core/utils/test.tsx"], setupFiles: ["./src/tests/setup.ts"],
}, },
}); });

Loading…
Cancel
Save