mirror of
https://github.com/meshtastic/web.git
synced 2026-04-23 23:38:04 -04:00
feat: add error handling for key mismatch
This commit is contained in:
@@ -8,6 +8,7 @@ import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
|
||||
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog.tsx";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
@@ -70,6 +71,12 @@ export const DialogManager = () => {
|
||||
setDialogOpen("unsafeRoles", open);
|
||||
}}
|
||||
/>
|
||||
<RefreshKeysDialog
|
||||
open={dialog.refreshKeys}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("refreshKeys", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 >
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
|
||||
}
|
||||
@@ -18,9 +18,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
|
||||
setSerialPorts(await navigator?.serial.getPorts());
|
||||
}, []);
|
||||
|
||||
navigator?.serial?.addEventListener("connect", (event) => {
|
||||
console.log(event);
|
||||
|
||||
navigator?.serial?.addEventListener("connect", () => {
|
||||
updateSerialPortList();
|
||||
});
|
||||
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 h-48 flex-col gap-2 overflow-y-auto">
|
||||
{serialPorts.map((port, index) => {
|
||||
console.log(port);
|
||||
|
||||
const { usbProductId, usbVendorId } = port.getInfo();
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -15,9 +15,10 @@ const EmptyState = () => (
|
||||
);
|
||||
|
||||
export const ChannelChat = ({
|
||||
messages,
|
||||
messages = [],
|
||||
}: ChannelChatProps) => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
const messagesEndRef = 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"
|
||||
>
|
||||
<div className="flex flex-col justify-end min-h-full">
|
||||
{messages.map((message, index) => (
|
||||
{messages?.map((message, index) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
message={message}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
@@ -12,15 +13,13 @@ import {
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
|
||||
|
||||
const MESSAGE_STATES = {
|
||||
ACK: "ack",
|
||||
WAITING: "waiting",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
type MessageStateValue = {
|
||||
state: string;
|
||||
icon: LucideIcon;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
type MessageState = MessageWithState["state"];
|
||||
|
||||
@@ -40,31 +39,36 @@ interface StatusIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_TEXT_MAP: Record<MessageState, string> = {
|
||||
[MESSAGE_STATES.ACK]: "Message delivered",
|
||||
[MESSAGE_STATES.WAITING]: "Waiting for delivery",
|
||||
[MESSAGE_STATES.FAILED]: "Delivery failed",
|
||||
const MESSAGE_STATES: Record<string, MessageStateValue> = {
|
||||
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
|
||||
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
|
||||
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) => (
|
||||
<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"
|
||||
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"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{getStatusText(state)}
|
||||
{getMessageState(state).displayText ?? "An unknown error occurred"};
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -72,13 +76,17 @@ const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
|
||||
);
|
||||
|
||||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||
const msgState = getMessageState(state);
|
||||
|
||||
const isFailed = msgState.state === 'failed'
|
||||
|
||||
const iconClass = cn(
|
||||
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 (
|
||||
<StatusTooltip state={state}>
|
||||
<Icon
|
||||
@@ -90,23 +98,7 @@ const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageTextStyles = (state: MessageState) => {
|
||||
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 }) => (
|
||||
const TimeDisplay = memo(({ 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()}
|
||||
@@ -118,9 +110,9 @@ const TimeDisplay = ({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
));
|
||||
|
||||
export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const isDeviceUser = useMemo(
|
||||
@@ -128,33 +120,47 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
getDevices()
|
||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||
.includes(message.from),
|
||||
[getDevices, message.from],
|
||||
[getDevices, message.from]
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col w-full px-4 justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-wrap items-start py-1",
|
||||
isDeviceUser && "items-end",
|
||||
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?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
</span>
|
||||
</div>
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimeDisplay date={message.rxTime} />
|
||||
<div className="flex place-items-center gap-2 pb-2">
|
||||
@@ -166,4 +172,4 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
126
src/components/PageComponents/Messages/MessageItem.tsx
Normal file
126
src/components/PageComponents/Messages/MessageItem.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
type RGBColor = {
|
||||
@@ -12,6 +13,7 @@ interface AvatarProps {
|
||||
text: string;
|
||||
size?: "sm" | "lg";
|
||||
className?: string;
|
||||
showError?: boolean;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
|
||||
@@ -43,6 +45,7 @@ class ColorUtils {
|
||||
export const Avatar: React.FC<AvatarProps> = ({
|
||||
text,
|
||||
size = "sm",
|
||||
showError = false,
|
||||
className,
|
||||
}) => {
|
||||
const sizes = {
|
||||
@@ -73,12 +76,11 @@ export const Avatar: React.FC<AvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`
|
||||
`flex
|
||||
relative
|
||||
rounded-full
|
||||
flex
|
||||
items-center
|
||||
justify-center
|
||||
size-11
|
||||
font-semibold`,
|
||||
sizes[size],
|
||||
className,
|
||||
@@ -88,6 +90,7 @@ export const Avatar: React.FC<AvatarProps> = ({
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ const buttonVariants = cva(
|
||||
success:
|
||||
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
|
||||
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:
|
||||
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
|
||||
ghost:
|
||||
|
||||
@@ -27,12 +27,18 @@ export type DialogVariant =
|
||||
| "nodeRemoval"
|
||||
| "pkiBackup"
|
||||
| "nodeDetails"
|
||||
| "unsafeRoles";
|
||||
| "unsafeRoles"
|
||||
| "refreshKeys";
|
||||
|
||||
type QueueStatus = {
|
||||
res: number, free: number, maxlen: number
|
||||
}
|
||||
|
||||
type NodeError = {
|
||||
node: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
status: Types.DeviceStatusEnum;
|
||||
@@ -52,6 +58,7 @@ export interface Device {
|
||||
number,
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||
>;
|
||||
nodeErrors: Map<number, NodeError>;
|
||||
connection?: MeshDevice;
|
||||
activePage: Page;
|
||||
activeNode: number;
|
||||
@@ -71,6 +78,7 @@ export interface Device {
|
||||
pkiBackup: boolean;
|
||||
nodeDetails: boolean;
|
||||
unsafeRoles: boolean;
|
||||
refreshKeys: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -109,6 +117,10 @@ export interface Device {
|
||||
processPacket: (data: ProcessPacketParams) => void;
|
||||
setMessageDraft: (message: string) => 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 {
|
||||
@@ -162,9 +174,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
pkiBackup: false,
|
||||
nodeDetails: false,
|
||||
unsafeRoles: false,
|
||||
refreshKeys: false,
|
||||
},
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
nodeErrors: new Map(),
|
||||
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => {
|
||||
set(
|
||||
@@ -531,7 +546,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
addTraceRoute: (traceroute) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
console.log("addTraceRoute called");
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
@@ -568,10 +582,8 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
console.log("setMessageState called");
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
console.log("no device found for id");
|
||||
return;
|
||||
}
|
||||
const messageGroup = device.messages[type];
|
||||
@@ -582,7 +594,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
const messages = messageGroup.get(messageIndex);
|
||||
|
||||
if (!messages) {
|
||||
console.log("no messages found for id");
|
||||
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);
|
||||
},
|
||||
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -22,15 +22,15 @@ export const subscribeAll = (
|
||||
) {
|
||||
return;
|
||||
}
|
||||
console.log(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
console.info(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
case "routeReply": {
|
||||
console.log(`Route Reply: ${routingPacket.data.variant.value}`);
|
||||
console.info(`Route Reply: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
case "routeRequest": {
|
||||
console.log(`Route Request: ${routingPacket.data.variant.value}`);
|
||||
console.info(`Route Request: ${routingPacket.data.variant.value}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,6 @@ export const subscribeAll = (
|
||||
});
|
||||
|
||||
connection.events.onChannelPacket.subscribe((channel) => {
|
||||
console.log('channel', channel);
|
||||
|
||||
device.addChannel(channel);
|
||||
});
|
||||
connection.events.onConfigPacket.subscribe((config) => {
|
||||
@@ -81,10 +79,8 @@ export const subscribeAll = (
|
||||
device.setModuleConfig(moduleConfig);
|
||||
});
|
||||
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
|
||||
console.log('messagePacket', messagePacket);
|
||||
|
||||
device.addMessage({
|
||||
...messagePacket,
|
||||
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
|
||||
@@ -111,8 +107,21 @@ export const subscribeAll = (
|
||||
|
||||
connection.events.onQueueStatus.subscribe((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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,9 +13,10 @@ import { getChannelName } from "@pages/Channels.tsx";
|
||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
export const MessagesPage = () => {
|
||||
const { channels, nodes, hardware, messages } = useDevice();
|
||||
const { channels, nodes, hardware, messages, hasNodeError } = useDevice();
|
||||
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const filteredNodes = Array.from(nodes.values()).filter((node) => {
|
||||
@@ -82,6 +83,8 @@ export const MessagesPage = () => {
|
||||
element={
|
||||
<Avatar
|
||||
text={node.user?.shortName ?? node.num.toString()}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
@@ -145,7 +148,6 @@ export const MessagesPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single message input for both chat types */}
|
||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||
<MessageInput
|
||||
to={messageDestination}
|
||||
|
||||
@@ -21,8 +21,6 @@ export interface DeleteNoteDialogProps {
|
||||
|
||||
const NodesPage = (): JSX.Element => {
|
||||
const { nodes, hardware, connection } = useDevice();
|
||||
console.log(connection);
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<
|
||||
Protobuf.Mesh.NodeInfo | undefined
|
||||
>(undefined);
|
||||
|
||||
@@ -44,9 +44,9 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
}
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['react-scan']
|
||||
|
||||
Reference in New Issue
Block a user