Browse Source

feat: add error handling for key mismatch

pull/516/head
Dan Ditomaso 1 year ago
parent
commit
3f8d3389d5
  1. 7
      src/components/Dialog/DialogManager.tsx
  2. 55
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  3. 60
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  4. 77
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
  5. 28
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  6. 6
      src/components/PageComponents/Connect/Serial.tsx
  7. 5
      src/components/PageComponents/Messages/ChannelChat.tsx
  8. 122
      src/components/PageComponents/Messages/Message.tsx
  9. 126
      src/components/PageComponents/Messages/MessageItem.tsx
  10. 11
      src/components/UI/Avatar.tsx
  11. 2
      src/components/UI/Button.tsx
  12. 58
      src/core/stores/deviceStore.ts
  13. 29
      src/core/subscriptions.ts
  14. 6
      src/pages/Messages.tsx
  15. 2
      src/pages/Nodes.tsx
  16. 6
      vite.config.ts

7
src/components/Dialog/DialogManager.tsx

@ -8,6 +8,7 @@ import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx"; import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx";
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
export const DialogManager = () => { export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
@ -70,6 +71,12 @@ export const DialogManager = () => {
setDialogOpen("unsafeRoles", open); setDialogOpen("unsafeRoles", open);
}} }}
/> />
<RefreshKeysDialog
open={dialog.refreshKeys}
onOpenChange={(open) => {
setDialogOpen("refreshKeys", open);
}}
/>
</> </>
); );
}; };

55
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx

@ -0,0 +1,55 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { RefreshKeysDialog } from "./RefreshKeysDialog";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
vi.mock("./useRefreshKeysDialog.ts", () => ({
useRefreshKeysDialog: vi.fn(),
}));
describe("RefreshKeysDialog Component", () => {
let handleCloseDialogMock: Mock;
let handleNodeRemoveMock: Mock;
let onOpenChangeMock: Mock;
beforeEach(() => {
handleCloseDialogMock = vi.fn();
handleNodeRemoveMock = vi.fn();
onOpenChangeMock = vi.fn();
(useRefreshKeysDialog as Mock).mockReturnValue({
handleCloseDialog: handleCloseDialogMock,
handleNodeRemove: handleNodeRemoveMock,
});
});
it("renders the dialog with correct content", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument();
expect(screen.getByText("Request New Keys")).toBeInTheDocument();
expect(screen.getByText("Dismiss")).toBeInTheDocument();
});
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Request New Keys"));
expect(handleNodeRemoveMock).toHaveBeenCalled();
});
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByText("Dismiss"));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
it("calls onOpenChange when dialog close button is clicked", () => {
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />);
fireEvent.click(screen.getByRole("button", { name: /close/i }));
expect(handleCloseDialogMock).toHaveBeenCalled();
});
it("does not render when open is false", () => {
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />);
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument();
});
});

60
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -0,0 +1,60 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react";
import { P } from "@components/UI/Typography/P.tsx";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
export interface RefreshKeysDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-8 flex flex-col gap-2">
<DialogClose onClick={handleCloseDialog} />
<DialogHeader>
<DialogTitle>Keys Mismatch</DialogTitle>
</DialogHeader>
Your node is unable to send a direct message to this node. This is due to public/private key mismatch.
<ul className="mt-2">
<li className="flex place-items-center gap-2 items-start">
<div className="p-2 bg-slate-500 rounded-lg mt-2">
<LockKeyholeOpenIcon size={30} className="text-white justify-center" />
</div>
<div className="flex flex-col gap-2" >
<P className="font-bold">Refresh this node</P>
<p>
This will remove the node from the chat and request new keys. The process may take a few moments to complete.
</p>
<Button
variant="default"
onClick={handleNodeRemove}
className=""
>
Request New Keys
</Button>
<Button
variant="outline"
onClick={handleCloseDialog}
className=""
>
Dismiss
</Button>
</div>
</li>
</ul>
{/* </DialogDescription> */}
</DialogContent>
</Dialog >
);
};

77
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts

@ -0,0 +1,77 @@
import { renderHook, act } from "@testing-library/react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useDevice } from "@core/stores/deviceStore.ts";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ activeChat: "chat-123" })),
}));
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(() => ({
removeNode: vi.fn(),
setDialogOpen: vi.fn(),
getNodeError: vi.fn(),
clearNodeError: vi.fn(),
})),
}));
describe("useRefreshKeysDialog Hook", () => {
let removeNodeMock: Mock;
let setDialogOpenMock: Mock;
let getNodeErrorMock: Mock;
let clearNodeErrorMock: Mock;
beforeEach(() => {
removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn();
clearNodeErrorMock = vi.fn();
(useDevice as Mock).mockReturnValue({
removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock,
});
});
it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
it("handleNodeRemove should do nothing if there is no error", () => {
getNodeErrorMock.mockReturnValue(undefined);
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleNodeRemove();
});
expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled();
expect(clearNodeErrorMock).not.toHaveBeenCalled();
});
it("handleCloseDialog should close the dialog", () => {
const { result } = renderHook(() => useRefreshKeysDialog());
act(() => {
result.current.handleCloseDialog();
});
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
});
});

28
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts

@ -0,0 +1,28 @@
import { useCallback } from "react";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
const { activeChat } = useAppStore();
const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat);
if (!nodeWithError) {
return;
}
clearNodeError(activeChat);
handleCloseDialog();;
return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, setDialogOpen, removeNode]);
const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false);
}, [setDialogOpen])
return {
handleCloseDialog,
handleNodeRemove
};
}

6
src/components/PageComponents/Connect/Serial.tsx

@ -18,9 +18,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
setSerialPorts(await navigator?.serial.getPorts()); setSerialPorts(await navigator?.serial.getPorts());
}, []); }, []);
navigator?.serial?.addEventListener("connect", (event) => { navigator?.serial?.addEventListener("connect", () => {
console.log(event);
updateSerialPortList(); updateSerialPortList();
}); });
navigator?.serial?.addEventListener("disconnect", () => { navigator?.serial?.addEventListener("disconnect", () => {
@ -47,8 +45,6 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
<div className="flex w-full flex-col gap-2 p-4"> <div className="flex w-full flex-col gap-2 p-4">
<div className="flex h-48 flex-col gap-2 overflow-y-auto"> <div className="flex h-48 flex-col gap-2 overflow-y-auto">
{serialPorts.map((port, index) => { {serialPorts.map((port, index) => {
console.log(port);
const { usbProductId, usbVendorId } = port.getInfo(); const { usbProductId, usbVendorId } = port.getInfo();
return ( return (
<Button <Button

5
src/components/PageComponents/Messages/ChannelChat.tsx

@ -15,9 +15,10 @@ const EmptyState = () => (
); );
export const ChannelChat = ({ export const ChannelChat = ({
messages, messages = [],
}: ChannelChatProps) => { }: ChannelChatProps) => {
const { nodes } = useDevice(); const { nodes } = useDevice();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -57,7 +58,7 @@ export const ChannelChat = ({
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44" className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
> >
<div className="flex flex-col justify-end min-h-full"> <div className="flex flex-col justify-end min-h-full">
{messages.map((message, index) => ( {messages?.map((message, index) => (
<Message <Message
key={message.id} key={message.id}
message={message} message={message}

122
src/components/PageComponents/Messages/Message.tsx

@ -1,3 +1,4 @@
import { memo, useMemo } from "react";
import { import {
Tooltip, Tooltip,
TooltipArrow, TooltipArrow,
@ -12,15 +13,13 @@ import {
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import type { Protobuf } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useMemo } from "react";
const MESSAGE_STATES = { type MessageStateValue = {
ACK: "ack", state: string;
WAITING: "waiting", icon: LucideIcon;
FAILED: "failed", displayText: string;
} as const; }
type MessageState = MessageWithState["state"]; type MessageState = MessageWithState["state"];
@ -40,31 +39,36 @@ interface StatusIconProps {
className?: string; className?: string;
} }
const STATUS_TEXT_MAP: Record<MessageState, string> = { const MESSAGE_STATES: Record<string, MessageStateValue> = {
[MESSAGE_STATES.ACK]: "Message delivered", ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
[MESSAGE_STATES.WAITING]: "Waiting for delivery", WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
[MESSAGE_STATES.FAILED]: "Delivery failed", FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" },
};
const STATUS_ICON_MAP: Record<MessageState, LucideIcon> = {
[MESSAGE_STATES.ACK]: CheckCircle2,
[MESSAGE_STATES.WAITING]: CircleEllipsis,
[MESSAGE_STATES.FAILED]: AlertCircle,
}; };
const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state]; const getMessageState = (state: MessageState): MessageStateValue => {
switch (state) {
case MESSAGE_STATES.ACK.state:
return MESSAGE_STATES.ACK;
case MESSAGE_STATES.WAITING.state:
return MESSAGE_STATES.WAITING;
case MESSAGE_STATES.FAILED.state:
return MESSAGE_STATES.FAILED;
default:
return MESSAGE_STATES.FAILED;
}
}
const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent <TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95" className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top" side="top"
align="center" align="center"
sideOffset={5} sideOffset={5}
> >
{getStatusText(state)} {getMessageState(state).displayText ?? "An unknown error occurred"};
<TooltipArrow className="fill-slate-800" /> <TooltipArrow className="fill-slate-800" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -72,13 +76,17 @@ const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
); );
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
const isFailed = state === MESSAGE_STATES.FAILED; const msgState = getMessageState(state);
const isFailed = msgState.state === 'failed'
const iconClass = cn( const iconClass = cn(
className, className,
"text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", "text-slate-500 dark:text-slate-400 size-5 shrink-0"
); );
const Icon = STATUS_ICON_MAP[state]; const Icon = msgState.icon;
return ( return (
<StatusTooltip state={state}> <StatusTooltip state={state}>
<Icon <Icon
@ -90,23 +98,7 @@ const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
); );
}; };
const getMessageTextStyles = (state: MessageState) => { const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => (
const isAcknowledged = state === MESSAGE_STATES.ACK;
const isFailed = state === MESSAGE_STATES.FAILED;
return cn(
"break-words overflow-hidden",
isAcknowledged
? "text-slate-900 dark:text-white"
: "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const TimeDisplay = ({
date,
className,
}: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}> <div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono"> <span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleDateString()} {date.toLocaleDateString()}
@ -118,9 +110,9 @@ const TimeDisplay = ({
})} })}
</span> </span>
</div> </div>
); ));
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo( const isDeviceUser = useMemo(
@ -128,33 +120,47 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
getDevices() getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) .map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from), .includes(message.from),
[getDevices, message.from], [getDevices, message.from]
); );
const messageUser = sender?.user; const messageUser = sender?.user;
const messageTextClass = getMessageTextStyles(message.state); const getMessageTextStyles = (state: MessageState) => {
const msgState = getMessageState(state);
const isAcknowledged = msgState.state === 'ack'
const isFailed = msgState.state === 'failed'
return cn(
"break-words overflow-hidden",
isAcknowledged
? "text-slate-900 dark:text-white"
: "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]);
return ( return (
<div className="flex flex-col w-full px-4 justify-start"> <div className="flex flex-col w-full px-4 justify-start">
<div <div
className={cn( className={cn(
"flex flex-col flex-wrap items-start py-1", "flex flex-col flex-wrap items-start py-1",
isDeviceUser && "items-end", isDeviceUser && "items-end"
)} )}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
{!lastMsgSameUser {!lastMsgSameUser && (
? ( <div className="flex place-items-center gap-2 mb-1">
<div className="flex place-items-center gap-2 mb-1"> <Avatar text={messageUser?.shortName ?? "UNK"} />
<Avatar text={messageUser?.shortName ?? "UNK"} /> <div className="flex flex-col">
<div className="flex flex-col"> <span className="font-medium text-slate-900 dark:text-white truncate">
<span className="font-medium text-slate-900 dark:text-white truncate"> {messageUser?.longName}
{messageUser?.longName} </span>
</span>
</div>
</div> </div>
) </div>
: null} )}
</div> </div>
<TimeDisplay date={message.rxTime} /> <TimeDisplay date={message.rxTime} />
<div className="flex place-items-center gap-2 pb-2"> <div className="flex place-items-center gap-2 pb-2">
@ -166,4 +172,4 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
</div> </div>
</div> </div>
); );
}; });

126
src/components/PageComponents/Messages/MessageItem.tsx

@ -0,0 +1,126 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
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 { Message, MessageState } from "@core/services/types.ts";
interface MessageProps {
lastMsgSameUser: boolean;
message: Message;
}
interface MessageStatus {
state: MessageState;
displayText: string;
icon: LucideIcon;
}
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 },
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis },
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle },
};
const getMessageStatus = (state: MessageState): MessageStatus =>
MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle };
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
{status.displayText}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
const isFailed = status.state === "failed";
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className);
const Icon = status.icon;
return (
<StatusTooltip status={status}>
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
</StatusTooltip>
);
};
const getMessageTextStyles = (status: MessageStatus) => {
const isAcknowledged = status.state === "ack";
const isFailed = status.state === "failed";
return cn(
"break-words overflow-hidden",
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400",
isFailed && "text-red-500 dark:text-red-500",
);
};
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
);
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo(
() =>
getDevices()
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
.includes(message.from),
[getDevices, message.from],
);
const messageUser = message?.from
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
: null;
const messageStatus = getMessageStatus(message.state);
const messageTextClass = getMessageTextStyles(messageStatus);
return (
<div className="flex flex-col w-full px-4 justify-start">
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
<div className="flex items-center gap-2 mb-2">
{!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.user?.longName}
</span>
</div>
</div>
)}
</div>
<TimeDisplay date={message.date} />
<div className="flex place-items-center gap-2 pb-2">
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
<StatusIcon status={messageStatus} />
</div>
</div>
</div>
);
};

11
src/components/UI/Avatar.tsx

@ -1,4 +1,5 @@
import { cn } from "../../core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon } from 'lucide-react';
import type React from "react"; import type React from "react";
type RGBColor = { type RGBColor = {
@ -12,6 +13,7 @@ interface AvatarProps {
text: string; text: string;
size?: "sm" | "lg"; size?: "sm" | "lg";
className?: string; className?: string;
showError?: boolean;
} }
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome // biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
@ -43,6 +45,7 @@ class ColorUtils {
export const Avatar: React.FC<AvatarProps> = ({ export const Avatar: React.FC<AvatarProps> = ({
text, text,
size = "sm", size = "sm",
showError = false,
className, className,
}) => { }) => {
const sizes = { const sizes = {
@ -73,12 +76,11 @@ export const Avatar: React.FC<AvatarProps> = ({
return ( return (
<div <div
className={cn( className={cn(
` `flex
relative
rounded-full rounded-full
flex
items-center items-center
justify-center justify-center
size-11
font-semibold`, font-semibold`,
sizes[size], sizes[size],
className, className,
@ -88,6 +90,7 @@ export const Avatar: React.FC<AvatarProps> = ({
color: textColor, color: textColor,
}} }}
> >
{showError ? <LockKeyholeOpenIcon className="size-4 absolute bottom-0 right-0 z-10 text-red-500 stroke-3" /> : null}
<p className="p-1">{initials}</p> <p className="p-1">{initials}</p>
</div> </div>
); );

2
src/components/UI/Button.tsx

@ -15,7 +15,7 @@ const buttonVariants = cva(
success: success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600", "bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline: outline:
"bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500", "bg-transparent border border-slate-400 hover:bg-slate-100 dark:border-slate-400 dark:text-slate-500",
subtle: subtle:
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400", "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost: ghost:

58
src/core/stores/deviceStore.ts

@ -27,12 +27,18 @@ export type DialogVariant =
| "nodeRemoval" | "nodeRemoval"
| "pkiBackup" | "pkiBackup"
| "nodeDetails" | "nodeDetails"
| "unsafeRoles"; | "unsafeRoles"
| "refreshKeys";
type QueueStatus = { type QueueStatus = {
res: number, free: number, maxlen: number res: number, free: number, maxlen: number
} }
type NodeError = {
node: number;
error: string;
}
export interface Device { export interface Device {
id: number; id: number;
status: Types.DeviceStatusEnum; status: Types.DeviceStatusEnum;
@ -52,6 +58,7 @@ export interface Device {
number, number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[] Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>; >;
nodeErrors: Map<number, NodeError>;
connection?: MeshDevice; connection?: MeshDevice;
activePage: Page; activePage: Page;
activeNode: number; activeNode: number;
@ -71,6 +78,7 @@ export interface Device {
pkiBackup: boolean; pkiBackup: boolean;
nodeDetails: boolean; nodeDetails: boolean;
unsafeRoles: boolean; unsafeRoles: boolean;
refreshKeys: boolean;
}; };
@ -109,6 +117,10 @@ export interface Device {
processPacket: (data: ProcessPacketParams) => void; processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void; setMessageDraft: (message: string) => void;
setQueueStatus: (status: QueueStatus) => void; setQueueStatus: (status: QueueStatus) => void;
setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean
} }
export interface DeviceState { export interface DeviceState {
@ -162,9 +174,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
pkiBackup: false, pkiBackup: false,
nodeDetails: false, nodeDetails: false,
unsafeRoles: false, unsafeRoles: false,
refreshKeys: false,
}, },
pendingSettingsChanges: false, pendingSettingsChanges: false,
messageDraft: "", messageDraft: "",
nodeErrors: new Map(),
setStatus: (status: Types.DeviceStatusEnum) => { setStatus: (status: Types.DeviceStatusEnum) => {
set( set(
@ -531,7 +546,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
addTraceRoute: (traceroute) => { addTraceRoute: (traceroute) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
console.log("addTraceRoute called");
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
return; return;
@ -568,10 +582,8 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
) => { ) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
console.log("setMessageState called");
const device = draft.devices.get(id); const device = draft.devices.get(id);
if (!device) { if (!device) {
console.log("no device found for id");
return; return;
} }
const messageGroup = device.messages[type]; const messageGroup = device.messages[type];
@ -582,7 +594,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
const messages = messageGroup.get(messageIndex); const messages = messageGroup.get(messageIndex);
if (!messages) { if (!messages) {
console.log("no messages found for id");
return; return;
} }
@ -663,7 +674,42 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
} }
}), }),
); );
} },
setNodeError: (nodeNum, error) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.set(nodeNum, { node: nodeNum, error });
}
}),
);
},
clearNodeError: (nodeNum: number) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.nodeErrors.delete(nodeNum);
}
}),
);
},
getNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
}
return device.nodeErrors.get(nodeNum);
},
hasNodeError: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
throw new Error("Device not found");
}
return device.nodeErrors.has(nodeNum);
},
}); });
}), }),
); );

29
src/core/subscriptions.ts

@ -22,15 +22,15 @@ export const subscribeAll = (
) { ) {
return; return;
} }
console.log(`Routing Error: ${routingPacket.data.variant.value}`); console.info(`Routing Error: ${routingPacket.data.variant.value}`);
break; break;
} }
case "routeReply": { case "routeReply": {
console.log(`Route Reply: ${routingPacket.data.variant.value}`); console.info(`Route Reply: ${routingPacket.data.variant.value}`);
break; break;
} }
case "routeRequest": { case "routeRequest": {
console.log(`Route Request: ${routingPacket.data.variant.value}`); console.info(`Route Request: ${routingPacket.data.variant.value}`);
break; break;
} }
} }
@ -70,8 +70,6 @@ export const subscribeAll = (
}); });
connection.events.onChannelPacket.subscribe((channel) => { connection.events.onChannelPacket.subscribe((channel) => {
console.log('channel', channel);
device.addChannel(channel); device.addChannel(channel);
}); });
connection.events.onConfigPacket.subscribe((config) => { connection.events.onConfigPacket.subscribe((config) => {
@ -81,10 +79,8 @@ export const subscribeAll = (
device.setModuleConfig(moduleConfig); device.setModuleConfig(moduleConfig);
}); });
connection.events.onMessagePacket.subscribe((messagePacket) => {
console.log('messagePacket', messagePacket);
connection.events.onMessagePacket.subscribe((messagePacket) => {
device.addMessage({ device.addMessage({
...messagePacket, ...messagePacket,
state: messagePacket.from !== myNodeNum ? "ack" : "waiting", state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
@ -111,8 +107,21 @@ export const subscribeAll = (
connection.events.onQueueStatus.subscribe((queueStatus) => { connection.events.onQueueStatus.subscribe((queueStatus) => {
device.setQueueStatus(queueStatus); device.setQueueStatus(queueStatus);
if (queueStatus.free < 10) { });
// start queueing messages
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
device.setDialogOpen("refreshKeys", true);
break;
default: {
break;
}
}
} }
}); });
}; };

6
src/pages/Messages.tsx

@ -13,9 +13,10 @@ import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts";
export const MessagesPage = () => { export const MessagesPage = () => {
const { channels, nodes, hardware, messages } = useDevice(); const { channels, nodes, hardware, messages, hasNodeError } = useDevice();
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore(); const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const filteredNodes = Array.from(nodes.values()).filter((node) => { const filteredNodes = Array.from(nodes.values()).filter((node) => {
@ -82,6 +83,8 @@ export const MessagesPage = () => {
element={ element={
<Avatar <Avatar
text={node.user?.shortName ?? node.num.toString()} text={node.user?.shortName ?? node.num.toString()}
className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
size="sm" size="sm"
/> />
} }
@ -145,7 +148,6 @@ export const MessagesPage = () => {
)} )}
</div> </div>
{/* Single message input for both chat types */}
<div className="shrink-0 p-4 w-full dark:bg-slate-900"> <div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput <MessageInput
to={messageDestination} to={messageDestination}

2
src/pages/Nodes.tsx

@ -21,8 +21,6 @@ export interface DeleteNoteDialogProps {
const NodesPage = (): JSX.Element => { const NodesPage = (): JSX.Element => {
const { nodes, hardware, connection } = useDevice(); const { nodes, hardware, connection } = useDevice();
console.log(connection);
const [selectedNode, setSelectedNode] = useState< const [selectedNode, setSelectedNode] = useState<
Protobuf.Mesh.NodeInfo | undefined Protobuf.Mesh.NodeInfo | undefined
>(undefined); >(undefined);

6
vite.config.ts

@ -44,9 +44,9 @@ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
headers: { headers: {
'Cross-Origin-Opener-Policy': 'same-origin', "Cross-Origin-Opener-Policy": "same-origin",
'Cross-Origin-Embedder-Policy': 'require-corp', "Cross-Origin-Embedder-Policy": "require-corp",
} },
}, },
optimizeDeps: { optimizeDeps: {
exclude: ['react-scan'] exclude: ['react-scan']

Loading…
Cancel
Save