16 changed files with 507 additions and 93 deletions
@ -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 |
||||
|
}; |
||||
|
|
||||
|
} |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
Loading…
Reference in new issue