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