committed by
GitHub
101 changed files with 1793 additions and 4353 deletions
@ -45,7 +45,6 @@ |
|||||
"npm:crypto-random-string@5": "5.0.0", |
"npm:crypto-random-string@5": "5.0.0", |
||||
"npm:gzipper@^8.2.1": "8.2.1", |
"npm:gzipper@^8.2.1": "8.2.1", |
||||
"npm:happy-dom@^17.4.4": "17.4.4", |
"npm:happy-dom@^17.4.4": "17.4.4", |
||||
"npm:idb-keyval@^6.2.1": "6.2.1", |
|
||||
"npm:immer@^10.1.1": "10.1.1", |
"npm:immer@^10.1.1": "10.1.1", |
||||
"npm:js-cookie@^3.0.5": "3.0.5", |
"npm:js-cookie@^3.0.5": "3.0.5", |
||||
"npm:[email protected]": "[email protected]", |
"npm:[email protected]": "[email protected]", |
||||
@ -4493,9 +4492,6 @@ |
|||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" |
"integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" |
||||
}, |
}, |
||||
"[email protected]": { |
|
||||
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" |
|
||||
}, |
|
||||
"[email protected]": { |
"[email protected]": { |
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" |
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" |
||||
}, |
}, |
||||
@ -6468,7 +6464,6 @@ |
|||||
"npm:@radix-ui/react-scroll-area@^1.2.3", |
"npm:@radix-ui/react-scroll-area@^1.2.3", |
||||
"npm:@radix-ui/react-select@^2.1.6", |
"npm:@radix-ui/react-select@^2.1.6", |
||||
"npm:@radix-ui/react-separator@^1.1.2", |
"npm:@radix-ui/react-separator@^1.1.2", |
||||
"npm:@radix-ui/react-slider@^1.3.2", |
|
||||
"npm:@radix-ui/react-switch@^1.1.3", |
"npm:@radix-ui/react-switch@^1.1.3", |
||||
"npm:@radix-ui/react-tabs@^1.1.3", |
"npm:@radix-ui/react-tabs@^1.1.3", |
||||
"npm:@radix-ui/react-toast@^1.2.6", |
"npm:@radix-ui/react-toast@^1.2.6", |
||||
@ -6496,7 +6491,6 @@ |
|||||
"npm:crypto-random-string@5", |
"npm:crypto-random-string@5", |
||||
"npm:gzipper@^8.2.1", |
"npm:gzipper@^8.2.1", |
||||
"npm:happy-dom@^17.4.4", |
"npm:happy-dom@^17.4.4", |
||||
"npm:idb-keyval@^6.2.1", |
|
||||
"npm:immer@^10.1.1", |
"npm:immer@^10.1.1", |
||||
"npm:js-cookie@^3.0.5", |
"npm:js-cookie@^3.0.5", |
||||
"npm:[email protected]", |
"npm:[email protected]", |
||||
|
|||||
|
Before Width: | Height: | Size: 1.9 KiB |
@ -1,86 +0,0 @@ |
|||||
import React from 'react'; |
|
||||
import { |
|
||||
PlugZapIcon, |
|
||||
BatteryFullIcon, |
|
||||
BatteryMediumIcon, |
|
||||
BatteryLowIcon, |
|
||||
} from 'lucide-react'; |
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; |
|
||||
|
|
||||
interface DeviceMetrics { |
|
||||
batteryLevel?: number | null; |
|
||||
voltage?: number | null; |
|
||||
} |
|
||||
|
|
||||
interface BatteryStatusProps { |
|
||||
deviceMetrics?: DeviceMetrics | null; |
|
||||
} |
|
||||
|
|
||||
interface BatteryStateConfig { |
|
||||
condition: (level: number) => boolean; |
|
||||
Icon: React.ElementType; |
|
||||
className: string; |
|
||||
text: (level: number) => string; |
|
||||
} |
|
||||
|
|
||||
const batteryStates: BatteryStateConfig[] = [ |
|
||||
{ |
|
||||
condition: level => level > 100, |
|
||||
Icon: PlugZapIcon, |
|
||||
className: 'text-gray-500', |
|
||||
text: () => 'Plugged in', |
|
||||
}, |
|
||||
{ |
|
||||
condition: level => level > 80, |
|
||||
Icon: BatteryFullIcon, |
|
||||
className: 'text-green-500', |
|
||||
text: level => `${level}% charging`, |
|
||||
}, |
|
||||
{ |
|
||||
condition: level => level > 20, |
|
||||
Icon: BatteryMediumIcon, |
|
||||
className: 'text-yellow-500', |
|
||||
text: level => `${level}% charging`, |
|
||||
}, |
|
||||
{ |
|
||||
condition: () => true, |
|
||||
Icon: BatteryLowIcon, |
|
||||
className: 'text-red-500', |
|
||||
text: level => `${level}% charging`, |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
const getBatteryState = (level: number) => { |
|
||||
return batteryStates.find(state => state.condition(level)); |
|
||||
}; |
|
||||
|
|
||||
|
|
||||
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => { |
|
||||
if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) { |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
const { batteryLevel, voltage } = deviceMetrics; |
|
||||
const currentState = getBatteryState(batteryLevel) ?? batteryStates[batteryStates.length - 1]; |
|
||||
|
|
||||
|
|
||||
const BatteryIcon = currentState.Icon; |
|
||||
const iconClassName = currentState.className; |
|
||||
const statusText = currentState.text(batteryLevel); |
|
||||
|
|
||||
const voltageTitle = `${voltage?.toPrecision(3) ?? 'Unknown'} volts`; |
|
||||
|
|
||||
return ( |
|
||||
<div |
|
||||
className="flex items-center gap-1 mt-0.5 text-gray-500" |
|
||||
title={voltageTitle} |
|
||||
> |
|
||||
<BatteryIcon size={22} className={iconClassName} /> |
|
||||
<Subtle aria-label="Battery"> |
|
||||
{statusText} |
|
||||
</Subtle> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
export default BatteryStatus; |
|
||||
@ -0,0 +1,76 @@ |
|||||
|
import { DeviceSelectorButton } from "@components/DeviceSelectorButton.tsx"; |
||||
|
import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; |
||||
|
import { Separator } from "@components/UI/Seperator.tsx"; |
||||
|
import { Code } from "@components/UI/Typography/Code.tsx"; |
||||
|
import { useAppStore } from "@core/stores/appStore.ts"; |
||||
|
import { useDeviceStore } from "@core/stores/deviceStore.ts"; |
||||
|
import { HomeIcon, PlusIcon, SearchIcon } from "lucide-react"; |
||||
|
import { Avatar } from "@components/UI/Avatar.tsx"; |
||||
|
|
||||
|
export const DeviceSelector = () => { |
||||
|
const { getDevices } = useDeviceStore(); |
||||
|
const { |
||||
|
selectedDevice, |
||||
|
setSelectedDevice, |
||||
|
setCommandPaletteOpen, |
||||
|
setConnectDialogOpen, |
||||
|
} = useAppStore(); |
||||
|
|
||||
|
return ( |
||||
|
<nav className="flex flex-col justify-between border-r-[0.5px] border-slate-300 pt-2 dark:border-slate-700"> |
||||
|
<div className="flex flex-col overflow-y-hidden"> |
||||
|
<ul className="flex w-20 grow flex-col items-center space-y-4 bg-transparent py-4 px-5"> |
||||
|
<DeviceSelectorButton |
||||
|
active={selectedDevice === 0} |
||||
|
onClick={() => { |
||||
|
setSelectedDevice(0); |
||||
|
}} |
||||
|
> |
||||
|
<HomeIcon /> |
||||
|
</DeviceSelectorButton> |
||||
|
{getDevices().map((device) => ( |
||||
|
<DeviceSelectorButton |
||||
|
key={device.id} |
||||
|
onClick={() => { |
||||
|
setSelectedDevice(device.id); |
||||
|
}} |
||||
|
active={selectedDevice === device.id} |
||||
|
> |
||||
|
<Avatar |
||||
|
text={device.nodes |
||||
|
.get(device.hardware.myNodeNum) |
||||
|
?.user?.shortName.toString() ?? "UNK"} |
||||
|
/> |
||||
|
</DeviceSelectorButton> |
||||
|
))} |
||||
|
<Separator /> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setConnectDialogOpen(true)} |
||||
|
className="transition-all duration-300" |
||||
|
> |
||||
|
<PlusIcon /> |
||||
|
</button> |
||||
|
</ul> |
||||
|
</div> |
||||
|
<div className="flex w-20 flex-col items-center space-y-5 px-5 pb-5"> |
||||
|
<ThemeSwitcher /> |
||||
|
<button |
||||
|
type="button" |
||||
|
className="transition-all hover:text-accent" |
||||
|
onClick={() => setCommandPaletteOpen(true)} |
||||
|
> |
||||
|
<SearchIcon /> |
||||
|
</button> |
||||
|
{/* TODO: This is being commented out until its fixed */} |
||||
|
{ |
||||
|
/* <button type="button" className="transition-all hover:text-accent"> |
||||
|
<LanguagesIcon /> |
||||
|
</button> */ |
||||
|
} |
||||
|
<Separator /> |
||||
|
<Code>{import.meta.env.VITE_COMMIT_HASH}</Code> |
||||
|
</div> |
||||
|
</nav> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,25 @@ |
|||||
|
export interface DeviceSelectorButtonProps { |
||||
|
active: boolean; |
||||
|
onClick: () => void; |
||||
|
children?: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const DeviceSelectorButton = ({ |
||||
|
onClick, |
||||
|
children, |
||||
|
}: DeviceSelectorButtonProps) => ( |
||||
|
<li |
||||
|
className="aspect-w-1 aspect-h-1 relative w-full" |
||||
|
onClick={onClick} |
||||
|
onKeyDown={onClick} |
||||
|
> |
||||
|
{ |
||||
|
/* {active && ( |
||||
|
<div className="absolute -left-2 h-10 w-1.5 rounded-full bg-accent" /> |
||||
|
)} */ |
||||
|
} |
||||
|
<div className="flex aspect-square cursor-pointer flex-col items-center justify-center"> |
||||
|
{children} |
||||
|
</div> |
||||
|
</li> |
||||
|
); |
||||
@ -1,69 +0,0 @@ |
|||||
import { render, screen, fireEvent } from '@testing-library/react'; |
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
|
||||
// Ensure the path is correct for import
|
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; |
|
||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; |
|
||||
|
|
||||
vi.mock('@core/stores/messageStore', () => ({ |
|
||||
useMessageStore: vi.fn(() => ({ |
|
||||
deleteAllMessages: vi.fn(), |
|
||||
})), |
|
||||
})); |
|
||||
|
|
||||
describe('DeleteMessagesDialog', () => { |
|
||||
const mockOnOpenChange = vi.fn(); |
|
||||
const mockClearAllMessages = vi.fn(); |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
mockOnOpenChange.mockClear(); |
|
||||
mockClearAllMessages.mockClear(); |
|
||||
|
|
||||
const mockedUseMessageStore = vi.mocked(useMessageStore); |
|
||||
mockedUseMessageStore.mockImplementation(() => ({ |
|
||||
deleteAllMessages: mockClearAllMessages |
|
||||
})); |
|
||||
mockedUseMessageStore.mockClear(); |
|
||||
|
|
||||
}); |
|
||||
|
|
||||
it('calls onOpenChange with false when the close button (X) is clicked', () => { |
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|
||||
const closeButton = screen.queryByTestId('dialog-close-button'); |
|
||||
if (!closeButton) { |
|
||||
throw new Error("Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?"); |
|
||||
} |
|
||||
fireEvent.click(closeButton); |
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); |
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false); |
|
||||
}); |
|
||||
|
|
||||
|
|
||||
|
|
||||
it('renders the dialog when open is true', () => { |
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|
||||
expect(screen.getByText('Clear All Messages')).toBeInTheDocument(); |
|
||||
expect(screen.getByText(/This action will clear all message history./)).toBeInTheDocument(); |
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); |
|
||||
expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
it('does not render the dialog when open is false', () => { |
|
||||
render(<DeleteMessagesDialog open={false} onOpenChange={mockOnOpenChange} />); |
|
||||
expect(screen.queryByText('Clear All Messages')).toBeNull(); |
|
||||
}); |
|
||||
|
|
||||
it('calls onOpenChange with false when the dismiss button is clicked', () => { |
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); |
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false); |
|
||||
}); |
|
||||
|
|
||||
it('calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked', () => { |
|
||||
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); |
|
||||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1); |
|
||||
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
|
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,63 +0,0 @@ |
|||||
|
|
||||
import { Button } from "@components/UI/Button.tsx"; |
|
||||
import { |
|
||||
Dialog, |
|
||||
DialogClose, |
|
||||
DialogContent, |
|
||||
DialogDescription, |
|
||||
DialogFooter, |
|
||||
DialogHeader, |
|
||||
DialogTitle, |
|
||||
} from "@components/UI/Dialog.tsx"; |
|
||||
import { AlertTriangleIcon } from "lucide-react"; |
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; |
|
||||
|
|
||||
export interface DeleteMessagesDialogProps { |
|
||||
open: boolean; |
|
||||
onOpenChange: (open: boolean) => void; |
|
||||
} |
|
||||
|
|
||||
export const DeleteMessagesDialog = ({ |
|
||||
open, |
|
||||
onOpenChange, |
|
||||
}: DeleteMessagesDialogProps) => { |
|
||||
const { deleteAllMessages } = useMessageStore(); |
|
||||
const handleCloseDialog = () => { |
|
||||
onOpenChange(false); |
|
||||
}; |
|
||||
|
|
||||
return ( |
|
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
|
||||
<DialogContent> |
|
||||
<DialogClose data-testid="dialog-close-button" /> |
|
||||
<DialogHeader> |
|
||||
<DialogTitle className="flex items-center gap-2"> |
|
||||
<AlertTriangleIcon className="h-5 w-5 text-warning" /> |
|
||||
Clear All Messages |
|
||||
</DialogTitle> |
|
||||
<DialogDescription> |
|
||||
This action will clear all message history. This cannot be undone. |
|
||||
Are you sure you want to continue? |
|
||||
</DialogDescription> |
|
||||
</DialogHeader> |
|
||||
<DialogFooter className="mt-4"> |
|
||||
<Button |
|
||||
variant="outline" |
|
||||
onClick={handleCloseDialog} |
|
||||
> |
|
||||
Dismiss |
|
||||
</Button> |
|
||||
<Button |
|
||||
variant="destructive" |
|
||||
onClick={() => { |
|
||||
deleteAllMessages(); |
|
||||
handleCloseDialog(); |
|
||||
}} |
|
||||
> |
|
||||
Clear Messages |
|
||||
</Button> |
|
||||
</DialogFooter> |
|
||||
</DialogContent> |
|
||||
</Dialog> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,97 +1,55 @@ |
|||||
import { render, screen } from "@testing-library/react"; |
import { render, screen, fireEvent } from "@testing-library/react"; |
||||
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts"; |
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; |
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; |
import { RefreshKeysDialog } from "./RefreshKeysDialog"; |
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; |
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; |
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; |
||||
import { expect, test, vi, beforeEach, afterEach } from 'vitest'; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
vi.mock("@core/stores/messageStore"); |
vi.mock("./useRefreshKeysDialog.ts", () => ({ |
||||
vi.mock("./useRefreshKeysDialog"); |
useRefreshKeysDialog: vi.fn(), |
||||
|
})); |
||||
|
|
||||
const mockUseMessageStore = vi.mocked(useMessageStore); |
describe("RefreshKeysDialog Component", () => { |
||||
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); |
let handleCloseDialogMock: Mock; |
||||
|
let handleNodeRemoveMock: Mock; |
||||
|
let onOpenChangeMock: Mock; |
||||
|
|
||||
const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() }; |
beforeEach(() => { |
||||
|
handleCloseDialogMock = vi.fn(); |
||||
|
handleNodeRemoveMock = vi.fn(); |
||||
|
onOpenChangeMock = vi.fn(); |
||||
|
|
||||
beforeEach(() => { |
(useRefreshKeysDialog as Mock).mockReturnValue({ |
||||
useDeviceStore.setState(getInitialState(), true); |
handleCloseDialog: handleCloseDialogMock, |
||||
vi.clearAllMocks(); |
handleNodeRemove: handleNodeRemoveMock, |
||||
}); |
}); |
||||
|
|
||||
afterEach(() => { |
|
||||
vi.restoreAllMocks(); |
|
||||
}); |
|
||||
|
|
||||
test("renders dialog when there is a node error for the active chat", () => { |
|
||||
const deviceId = 1; |
|
||||
const nodeWithErrorNum = 12345; |
|
||||
const activeChatNum = nodeWithErrorNum; |
|
||||
|
|
||||
const deviceStore = useDeviceStore.getState().addDevice(deviceId); |
|
||||
|
|
||||
deviceStore.addNodeInfo({ |
|
||||
num: nodeWithErrorNum, |
|
||||
user: { |
|
||||
id: nodeWithErrorNum.toString(), |
|
||||
publicKey: new Uint8Array(0), |
|
||||
hwModel: Protobuf.Mesh.HardwareModel.HELTEC_V3, |
|
||||
longName: "Problem Node Long", |
|
||||
shortName: "ProbNode", |
|
||||
isLicensed: false, |
|
||||
macaddr: new Uint8Array(0) |
|
||||
}, |
|
||||
lastHeard: Date.now() / 1000, |
|
||||
snr: 10 |
|
||||
} as Protobuf.Mesh.NodeInfo); |
|
||||
|
|
||||
deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH"); |
|
||||
|
|
||||
const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId); |
|
||||
if (!updatedDeviceState) { |
|
||||
throw new Error("Failed to get updated device state from store for provider"); |
|
||||
} |
|
||||
|
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); |
|
||||
const mockHandleClose = vi.fn(); |
|
||||
const mockHandleRemove = vi.fn(); |
|
||||
mockUseRefreshKeysDialog.mockReturnValue({ |
|
||||
handleCloseDialog: mockHandleClose, |
|
||||
handleNodeRemove: mockHandleRemove, |
|
||||
}); |
}); |
||||
|
|
||||
render( |
it("renders the dialog with correct content", () => { |
||||
<DeviceContext.Provider value={updatedDeviceState}> |
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); |
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} /> |
expect(screen.getByText("Keys Mismatch")).toBeInTheDocument(); |
||||
</DeviceContext.Provider> |
expect(screen.getByText("Request New Keys")).toBeInTheDocument(); |
||||
); |
expect(screen.getByText("Dismiss")).toBeInTheDocument(); |
||||
|
}); |
||||
expect(screen.getByText(/Keys Mismatch - Problem Node Long/)).toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/)).toBeInTheDocument(); |
|
||||
expect(screen.getByRole("button", { name: "Request New Keys" })).toBeInTheDocument(); |
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
test("does not render dialog if no error exists for active chat", () => { |
|
||||
const deviceId = 1; |
|
||||
const activeChatNum = 54321; |
|
||||
|
|
||||
useDeviceStore.getState().addDevice(deviceId); |
|
||||
|
|
||||
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); |
it("calls handleNodeRemove when 'Request New Keys' button is clicked", () => { |
||||
if (!currentDeviceState) throw new Error("Device not found"); |
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); |
||||
|
fireEvent.click(screen.getByText("Request New Keys")); |
||||
|
expect(handleNodeRemoveMock).toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); |
it("calls handleCloseDialog when 'Dismiss' button is clicked", () => { |
||||
mockUseRefreshKeysDialog.mockReturnValue({ |
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); |
||||
handleCloseDialog: vi.fn(), |
fireEvent.click(screen.getByText("Dismiss")); |
||||
handleNodeRemove: vi.fn(), |
expect(handleCloseDialogMock).toHaveBeenCalled(); |
||||
}); |
}); |
||||
|
|
||||
const { container } = render( |
it("calls onOpenChange when dialog close button is clicked", () => { |
||||
<DeviceContext.Provider value={currentDeviceState}> |
render(<RefreshKeysDialog open={true} onOpenChange={onOpenChangeMock} />); |
||||
<RefreshKeysDialog open onOpenChange={vi.fn()} /> |
fireEvent.click(screen.getByRole("button", { name: /close/i })); |
||||
</DeviceContext.Provider> |
expect(handleCloseDialogMock).toHaveBeenCalled(); |
||||
); |
}); |
||||
|
|
||||
expect(container.firstChild).toBeNull(); |
it("does not render when open is false", () => { |
||||
|
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />); |
||||
|
expect(screen.queryByText("Keys Mismatch")).not.toBeInTheDocument(); |
||||
|
}); |
||||
}); |
}); |
||||
|
|||||
@ -1,77 +1,77 @@ |
|||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; |
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts"; |
||||
|
import { Message } from "@components/PageComponents/Messages/Message.tsx"; |
||||
import { InboxIcon } from "lucide-react"; |
import { InboxIcon } from "lucide-react"; |
||||
import { useCallback, useEffect, useRef } from "react"; |
import { useCallback, useEffect, useRef } from "react"; |
||||
import { Message } from "@core/stores/messageStore/types.ts"; |
|
||||
|
|
||||
export interface ChannelChatProps { |
export interface ChannelChatProps { |
||||
messages?: Message[]; |
messages?: MessageWithState[]; |
||||
} |
} |
||||
|
|
||||
const EmptyState = () => ( |
const EmptyState = () => ( |
||||
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400"> |
<div className="flex flex-col place-content-center place-items-center p-8 text-white"> |
||||
<InboxIcon className="mb-2 h-8 w-8" /> |
<InboxIcon className="h-8 w-8 mb-2" /> |
||||
<span className="text-sm">No Messages</span> |
<span className="text-sm">No Messages</span> |
||||
</div> |
</div> |
||||
); |
); |
||||
|
|
||||
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { |
export const ChannelChat = ({ |
||||
const messagesEndRef = useRef<HTMLDivElement>(null); |
messages = [], |
||||
const scrollContainerRef = useRef<HTMLUListElement>(null); |
}: ChannelChatProps) => { |
||||
const userScrolledUpRef = useRef(false); |
const { nodes } = useDevice(); |
||||
|
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { |
const messagesEndRef = useRef<HTMLDivElement>(null); |
||||
requestAnimationFrame(() => { |
const scrollContainerRef = useRef<HTMLDivElement>(null); |
||||
messagesEndRef.current?.scrollIntoView({ behavior }); |
|
||||
}); |
|
||||
}, []); |
|
||||
|
|
||||
useEffect(() => { |
const scrollToBottom = useCallback(() => { |
||||
const scrollContainer = scrollContainerRef.current; |
const scrollContainer = scrollContainerRef.current; |
||||
if (!scrollContainer) return; |
if (scrollContainer) { |
||||
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; |
const isNearBottom = |
||||
|
scrollContainer.scrollHeight - |
||||
|
scrollContainer.scrollTop - |
||||
|
scrollContainer.clientHeight < |
||||
|
100; |
||||
|
|
||||
if (isScrolledToBottom || !userScrolledUpRef.current) { |
if (isNearBottom) { |
||||
scrollToBottom('smooth'); |
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
||||
|
} |
||||
} |
} |
||||
}, [messages, scrollToBottom]); |
}, []); |
||||
|
|
||||
useEffect(() => { |
useEffect(() => { |
||||
const scrollContainer = scrollContainerRef.current; |
scrollToBottom(); |
||||
const handleScroll = () => { |
}, [scrollToBottom, messages]); |
||||
if (!scrollContainer) return; |
|
||||
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; |
|
||||
userScrolledUpRef.current = !isAtBottom; |
|
||||
}; |
|
||||
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true }); |
|
||||
return () => { |
|
||||
scrollContainer?.removeEventListener('scroll', handleScroll); |
|
||||
}; |
|
||||
}, []); |
|
||||
|
|
||||
if (!messages?.length) { |
if (!messages?.length) { |
||||
return ( |
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center"> |
<div className="flex flex-col h-full container mx-auto"> |
||||
<EmptyState /> |
<div className="flex-1 flex items-center justify-center"> |
||||
<div ref={messagesEndRef} /> |
<EmptyState /> |
||||
|
</div> |
||||
</div> |
</div> |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
return ( |
return ( |
||||
<ul |
<div className="flex flex-col h-full container mx-auto"> |
||||
ref={scrollContainerRef} |
<div |
||||
className="flex flex-col flex-grow overflow-y-auto px-3 py-2" |
ref={scrollContainerRef} |
||||
> |
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44" |
||||
<div className="flex-grow" /> |
> |
||||
|
<div className="flex flex-col justify-end min-h-full"> |
||||
{messages?.map((message) => ( |
{messages?.map((message, index) => ( |
||||
<MessageItem |
<Message |
||||
key={message.messageId ?? `${message.from}-${message.date}`} |
key={message.id} |
||||
message={message} |
message={message} |
||||
/> |
sender={nodes.get(message.from)} |
||||
))} |
lastMsgSameUser={ |
||||
|
index > 0 && messages[index - 1].from === message.from |
||||
|
} |
||||
|
/> |
||||
|
))} |
||||
|
<div ref={messagesEndRef} className="w-full" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
<div ref={messagesEndRef} className="h-px" /> |
</div> |
||||
</ul> |
|
||||
); |
); |
||||
}; |
}; |
||||
@ -0,0 +1,175 @@ |
|||||
|
import { memo, useMemo } from "react"; |
||||
|
import { |
||||
|
Tooltip, |
||||
|
TooltipArrow, |
||||
|
TooltipContent, |
||||
|
TooltipProvider, |
||||
|
TooltipTrigger, |
||||
|
} from "@components/UI/Tooltip.tsx"; |
||||
|
import { |
||||
|
type MessageWithState, |
||||
|
useDeviceStore, |
||||
|
} from "@core/stores/deviceStore.ts"; |
||||
|
import { cn } from "@core/utils/cn.ts"; |
||||
|
import { Avatar } from "@components/UI/Avatar.tsx"; |
||||
|
import type { Protobuf } from "@meshtastic/core"; |
||||
|
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react"; |
||||
|
|
||||
|
type MessageStateValue = { |
||||
|
state: string; |
||||
|
icon: LucideIcon; |
||||
|
displayText: string; |
||||
|
} |
||||
|
|
||||
|
type MessageState = MessageWithState["state"]; |
||||
|
|
||||
|
interface MessageProps { |
||||
|
lastMsgSameUser: boolean; |
||||
|
message: MessageWithState; |
||||
|
sender: Protobuf.Mesh.NodeInfo; |
||||
|
} |
||||
|
|
||||
|
interface StatusTooltipProps { |
||||
|
state: MessageState; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
interface StatusIconProps { |
||||
|
state: MessageState; |
||||
|
className?: string; |
||||
|
} |
||||
|
|
||||
|
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 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 dark:text-white shadow-md animate-in fade-in-0 zoom-in-95" |
||||
|
side="top" |
||||
|
align="center" |
||||
|
sideOffset={5} |
||||
|
> |
||||
|
{getMessageState(state).displayText ?? "An unknown error occurred"}; |
||||
|
<TooltipArrow className="fill-slate-800" /> |
||||
|
</TooltipContent> |
||||
|
</Tooltip> |
||||
|
</TooltipProvider> |
||||
|
); |
||||
|
|
||||
|
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { |
||||
|
const msgState = getMessageState(state); |
||||
|
|
||||
|
const isFailed = msgState.state === 'failed' |
||||
|
|
||||
|
const iconClass = cn( |
||||
|
className, |
||||
|
"text-slate-500 dark:text-slate-400 size-5 shrink-0" |
||||
|
); |
||||
|
|
||||
|
const Icon = msgState.icon; |
||||
|
|
||||
|
return ( |
||||
|
<StatusTooltip state={state}> |
||||
|
<Icon |
||||
|
className={iconClass} |
||||
|
{...otherProps} |
||||
|
color={isFailed ? "red" : "currentColor"} |
||||
|
/> |
||||
|
</StatusTooltip> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
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()} |
||||
|
</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 Message = memo(({ lastMsgSameUser, message, sender }: 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 = sender?.user; |
||||
|
|
||||
|
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" |
||||
|
)} |
||||
|
> |
||||
|
<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> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
<TimeDisplay date={message.rxTime} /> |
||||
|
<div className="flex place-items-center gap-2 pb-2"> |
||||
|
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}> |
||||
|
{message.data} |
||||
|
</div> |
||||
|
<StatusIcon state={message.state} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}); |
||||
@ -1,91 +0,0 @@ |
|||||
import { |
|
||||
Tooltip, |
|
||||
TooltipArrow, |
|
||||
TooltipContent, |
|
||||
TooltipProvider, |
|
||||
TooltipTrigger, |
|
||||
} from "@components/UI/Tooltip.tsx"; |
|
||||
import { cn } from "@core/utils/cn.ts"; |
|
||||
import { SmilePlus, Reply } from "lucide-react"; |
|
||||
|
|
||||
interface MessageActionsMenuProps { |
|
||||
onAddReaction?: () => void; |
|
||||
onReply?: () => void; |
|
||||
} |
|
||||
|
|
||||
export const MessageActionsMenu = ({ |
|
||||
onAddReaction, |
|
||||
onReply |
|
||||
}: MessageActionsMenuProps) => { |
|
||||
const hoverIconBarClass = cn( |
|
||||
"absolute top-2 right-2", |
|
||||
"flex items-center gap-x-1", |
|
||||
"bg-white dark:bg-zinc-800", |
|
||||
"border border-gray-200 dark:border-zinc-600", |
|
||||
"rounded-md shadow-sm p-1", |
|
||||
"opacity-0 group-hover:opacity-100", |
|
||||
"transition-opacity duration-100 ease-in-out", |
|
||||
"z-10" |
|
||||
); |
|
||||
|
|
||||
const hoverIconButtonClass = cn( |
|
||||
"p-1 rounded", |
|
||||
"text-gray-500 dark:text-gray-400", |
|
||||
"hover:text-gray-700 dark:hover:text-gray-300", |
|
||||
"hover:bg-gray-100 dark:hover:bg-zinc-700", |
|
||||
"cursor-pointer" |
|
||||
); |
|
||||
|
|
||||
const iconSizeClass = "size-4"; |
|
||||
|
|
||||
return ( |
|
||||
<div className={cn(hoverIconBarClass)} onClick={(e) => e.stopPropagation()}> |
|
||||
<TooltipProvider delayDuration={300}> |
|
||||
<Tooltip> |
|
||||
<TooltipTrigger asChild> |
|
||||
<button |
|
||||
type="button" |
|
||||
aria-label="Add Reaction" |
|
||||
onClick={(e) => { |
|
||||
e.stopPropagation() |
|
||||
if (onAddReaction) { |
|
||||
onAddReaction(); |
|
||||
} |
|
||||
}} |
|
||||
className={hoverIconButtonClass} |
|
||||
> |
|
||||
<SmilePlus className={iconSizeClass} aria-hidden="true" /> |
|
||||
</button> |
|
||||
</TooltipTrigger> |
|
||||
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs"> |
|
||||
Add Reaction |
|
||||
<TooltipArrow className="fill-gray-800" /> |
|
||||
</TooltipContent> |
|
||||
</Tooltip> |
|
||||
|
|
||||
<Tooltip> |
|
||||
<TooltipTrigger asChild> |
|
||||
<button |
|
||||
type="button" |
|
||||
aria-label="Reply" |
|
||||
onClick={(e) => { |
|
||||
e.stopPropagation() |
|
||||
if (onReply) { |
|
||||
onReply(); |
|
||||
} |
|
||||
}} |
|
||||
className={hoverIconButtonClass} |
|
||||
> |
|
||||
<Reply className={iconSizeClass} aria-hidden="true" /> |
|
||||
</button> |
|
||||
</TooltipTrigger> |
|
||||
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs"> |
|
||||
Reply |
|
||||
<TooltipArrow className="fill-gray-800" /> |
|
||||
</TooltipContent> |
|
||||
</Tooltip> |
|
||||
</TooltipProvider> |
|
||||
|
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,222 +1,152 @@ |
|||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; |
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx'; |
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'; |
import { useDevice } from "@core/stores/deviceStore.ts"; |
||||
import { MessageInput, MessageInputProps } from './MessageInput.tsx'; |
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; |
||||
import { Types } from '@meshtastic/core'; |
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
||||
|
import userEvent from '@testing-library/user-event'; |
||||
vi.mock('@components/UI/Button.tsx', () => ({ |
|
||||
Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => ( |
vi.mock("@core/stores/deviceStore.ts", () => ({ |
||||
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}> |
useDevice: vi.fn(), |
||||
{children} |
|
||||
</button> |
|
||||
)), |
|
||||
})); |
})); |
||||
|
|
||||
vi.mock('@components/UI/Input.tsx', () => ({ |
vi.mock("@core/utils/debounce.ts", () => ({ |
||||
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => ( |
debounce: (fn: () => void) => fn, |
||||
<input |
|
||||
autoFocus={autoFocus} |
|
||||
minLength={minLength} |
|
||||
name={name} |
|
||||
placeholder={placeholder} |
|
||||
value={value} |
|
||||
onChange={onChange} |
|
||||
data-testid="message-input-field" |
|
||||
/> |
|
||||
)), |
|
||||
})); |
})); |
||||
|
|
||||
const mockSetDraft = vi.fn(); |
vi.mock("@components/UI/Button.tsx", () => ({ |
||||
const mockGetDraft = vi.fn(); |
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button> |
||||
const mockClearDraft = vi.fn(); |
|
||||
|
|
||||
vi.mock('@core/stores/messageStore', () => ({ |
|
||||
useMessageStore: vi.fn(() => ({ |
|
||||
setDraft: mockSetDraft, |
|
||||
getDraft: mockGetDraft, |
|
||||
clearDraft: mockClearDraft, |
|
||||
})), |
|
||||
MessageState: { |
|
||||
Ack: 'ack', |
|
||||
Waiting: 'waiting', |
|
||||
Failed: 'failed', |
|
||||
}, |
|
||||
MessageType: { |
|
||||
Direct: 'direct', |
|
||||
Broadcast: 'broadcast', |
|
||||
}, |
|
||||
})); |
})); |
||||
|
|
||||
vi.mock('lucide-react', () => ({ |
vi.mock("@components/UI/Input.tsx", () => ({ |
||||
SendIcon: vi.fn(() => <svg data-testid="send-icon" />), |
Input: (props: any) => <input {...props} /> |
||||
})); |
})); |
||||
|
|
||||
describe('MessageInput', () => { |
vi.mock("lucide-react", () => ({ |
||||
const mockOnSend = vi.fn(); |
SendIcon: () => <div data-testid="send-icon">Send</div> |
||||
const defaultProps: MessageInputProps = { |
})); |
||||
onSend: mockOnSend, |
|
||||
to: 123, |
// TODO: getting an error with this test
|
||||
maxBytes: 256, |
describe('MessageInput Component', () => { |
||||
|
const mockProps = { |
||||
|
to: "broadcast" as const, |
||||
|
channel: 0 as const, |
||||
|
maxBytes: 100, |
||||
}; |
}; |
||||
|
|
||||
|
const mockSetMessageDraft = vi.fn(); |
||||
|
const mockSetMessageState = vi.fn(); |
||||
|
const mockSendText = vi.fn().mockResolvedValue(123); |
||||
|
|
||||
beforeEach(() => { |
beforeEach(() => { |
||||
vi.clearAllMocks(); |
vi.clearAllMocks(); |
||||
|
|
||||
mockGetDraft.mockReturnValue(''); |
(useDevice as Mock).mockReturnValue({ |
||||
|
connection: { |
||||
|
sendText: mockSendText, |
||||
|
}, |
||||
|
setMessageState: mockSetMessageState, |
||||
|
messageDraft: "", |
||||
|
setMessageDraft: mockSetMessageDraft, |
||||
|
hardware: { |
||||
|
myNodeNum: 1234567890, |
||||
|
}, |
||||
|
}); |
||||
}); |
}); |
||||
|
|
||||
const renderComponent = (props: Partial<MessageInputProps> = {}) => { |
it('renders correctly with initial state', () => { |
||||
render(<MessageInput {...defaultProps} {...props} />); |
render(<MessageInput {...mockProps} />); |
||||
}; |
|
||||
|
|
||||
it('should render the input field, byte counter, and send button', () => { |
|
||||
renderComponent(); |
|
||||
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument(); |
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument(); |
||||
expect(screen.getByTestId('byte-counter')).toBeInTheDocument(); |
|
||||
expect(screen.getByRole('button')).toBeInTheDocument(); |
|
||||
expect(screen.getByTestId('send-icon')).toBeInTheDocument(); |
expect(screen.getByTestId('send-icon')).toBeInTheDocument(); |
||||
}); |
|
||||
|
|
||||
it('should initialize with the draft from the store', () => { |
expect(screen.getByText('0/100')).toBeInTheDocument(); |
||||
const initialDraft = 'Existing draft message'; |
|
||||
mockGetDraft.mockImplementation((key) => { |
|
||||
return key === defaultProps.to ? initialDraft : ''; |
|
||||
}); |
|
||||
|
|
||||
renderComponent(); |
|
||||
|
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; |
|
||||
expect(inputElement.value).toBe(initialDraft); |
|
||||
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to); |
|
||||
const expectedBytes = new Blob([initialDraft]).size; |
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`); |
|
||||
}); |
}); |
||||
|
|
||||
it('should update input value, byte counter, and call setDraft on change within limits', () => { |
it('updates local draft and byte count when typing', () => { |
||||
renderComponent(); |
render(<MessageInput {...mockProps} />); |
||||
const inputElement = screen.getByPlaceholderText('Enter Message'); |
|
||||
const testMessage = 'Hello there!'; |
|
||||
const expectedBytes = new Blob([testMessage]).size; |
|
||||
|
|
||||
fireEvent.change(inputElement, { target: { value: testMessage } }); |
const inputField = screen.getByPlaceholderText('Enter Message'); |
||||
|
fireEvent.change(inputField, { target: { value: 'Hello' } }) |
||||
|
|
||||
expect((inputElement as HTMLInputElement).value).toBe(testMessage); |
expect(screen.getByText('5/100')).toBeInTheDocument(); |
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`); |
expect(inputField).toHaveValue('Hello'); |
||||
expect(mockSetDraft).toHaveBeenCalledTimes(1); |
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello'); |
||||
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage); |
|
||||
}); |
}); |
||||
|
|
||||
it('should NOT update input value or call setDraft if maxBytes is exceeded', () => { |
it.skip('does not allow input exceeding max bytes', () => { |
||||
const smallMaxBytes = 5; |
render(<MessageInput {...mockProps} maxBytes={5} />); |
||||
renderComponent({ maxBytes: smallMaxBytes }); |
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message'); |
|
||||
const initialValue = '12345'; |
|
||||
const excessiveValue = '123456'; |
|
||||
|
|
||||
fireEvent.change(inputElement, { target: { value: initialValue } }); |
const inputField = screen.getByPlaceholderText('Enter Message'); |
||||
expect((inputElement as HTMLInputElement).value).toBe(initialValue); |
|
||||
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue); |
|
||||
mockSetDraft.mockClear(); |
|
||||
|
|
||||
fireEvent.change(inputElement, { target: { value: excessiveValue } }); |
expect(screen.getByText('0/100')).toBeInTheDocument(); |
||||
|
|
||||
expect((inputElement as HTMLInputElement).value).toBe(initialValue); |
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p') |
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`); |
|
||||
expect(mockSetDraft).not.toHaveBeenCalled(); |
|
||||
}); |
|
||||
|
|
||||
it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => { |
expect(screen.getByText('100/100')).toBeInTheDocument(); |
||||
renderComponent(); |
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'); |
||||
const inputElement = screen.getByPlaceholderText('Enter Message'); |
|
||||
const formElement = screen.getByRole('form'); |
|
||||
const testMessage = 'Send this message'; |
|
||||
|
|
||||
fireEvent.change(inputElement, { target: { value: testMessage } }); |
|
||||
fireEvent.submit(formElement); |
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1); |
|
||||
expect(mockOnSend).toHaveBeenCalledWith(testMessage); |
|
||||
expect((inputElement as HTMLInputElement).value).toBe(''); |
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`); |
|
||||
expect(mockClearDraft).toHaveBeenCalledTimes(1); |
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); |
|
||||
}); |
|
||||
}); |
}); |
||||
|
|
||||
it('should trim whitespace before calling onSend', async () => { |
it.skip('sends message and resets form when submitting', async () => { |
||||
renderComponent(); |
try { |
||||
const inputElement = screen.getByPlaceholderText('Enter Message'); |
render(<MessageInput {...mockProps} />); |
||||
const formElement = screen.getByRole('form'); |
|
||||
const testMessageWithWhitespace = ' Trim me! '; |
|
||||
const expectedTrimmedMessage = 'Trim me!'; |
|
||||
|
|
||||
fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } }); |
const inputField = screen.getByPlaceholderText('Enter Message'); |
||||
fireEvent.submit(formElement); |
const submitButton = screen.getByText('Send'); |
||||
|
|
||||
await waitFor(() => { |
fireEvent.change(inputField, { target: { value: 'Test Message' } }); |
||||
expect(mockOnSend).toHaveBeenCalledTimes(1); |
fireEvent.click(submitButton); |
||||
expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage); |
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it('should not call onSend or clearDraft if input is empty on submit', async () => { |
const form = screen.getByRole('form'); |
||||
renderComponent(); |
fireEvent.submit(form); |
||||
const inputElement = screen.getByPlaceholderText('Enter Message'); |
|
||||
const formElement = screen.getByRole('form'); |
|
||||
|
|
||||
expect((inputElement as HTMLInputElement).value).toBe(''); |
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0); |
||||
|
|
||||
fireEvent.submit(formElement); |
await waitFor(() => { |
||||
|
expect(mockSetMessageState).toHaveBeenCalledWith( |
||||
|
'broadcast', |
||||
|
0, |
||||
|
'broadcast', |
||||
|
1234567890, |
||||
|
123, |
||||
|
'ack' |
||||
|
); |
||||
|
|
||||
await act(async () => { |
}); |
||||
await new Promise(resolve => setTimeout(resolve, 50)); |
|
||||
}); |
|
||||
|
|
||||
expect(mockOnSend).not.toHaveBeenCalled(); |
expect(inputField).toHaveValue(''); |
||||
expect(mockClearDraft).not.toHaveBeenCalled(); |
expect(screen.getByText('0/100')).toBeInTheDocument(); |
||||
|
expect(mockSetMessageDraft).toHaveBeenCalledWith(''); |
||||
|
} catch (e) { |
||||
|
console.error(e); |
||||
|
} |
||||
}); |
}); |
||||
|
it('prevents sending empty messages', () => { |
||||
|
render(<MessageInput {...mockProps} />); |
||||
|
|
||||
it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => { |
const form = screen.getByPlaceholderText('Enter Message') |
||||
renderComponent(); |
fireEvent.submit(form); |
||||
const inputElement = screen.getByTestId('message-input-field'); |
|
||||
const formElement = screen.getByRole('form'); |
|
||||
const whitespaceMessage = ' \t '; |
|
||||
|
|
||||
fireEvent.change(inputElement, { target: { value: whitespaceMessage } }); |
|
||||
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); |
|
||||
|
|
||||
fireEvent.submit(formElement); |
|
||||
|
|
||||
await act(async () => { |
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); |
|
||||
}); |
|
||||
|
|
||||
expect(mockOnSend).not.toHaveBeenCalled(); |
expect(mockSendText).not.toHaveBeenCalled(); |
||||
expect(mockClearDraft).not.toHaveBeenCalled(); |
|
||||
|
|
||||
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage); |
|
||||
}); |
}); |
||||
|
|
||||
it('should work with broadcast destination for drafts', () => { |
it('initializes with existing message draft', () => { |
||||
const broadcastDest: Types.Destination = 'broadcast'; |
(useDevice as Mock).mockReturnValue({ |
||||
mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : ''); |
connection: { |
||||
|
sendText: mockSendText, |
||||
renderComponent({ to: broadcastDest }); |
}, |
||||
|
setMessageState: mockSetMessageState, |
||||
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest); |
messageDraft: "Existing draft", |
||||
expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft'); |
setMessageDraft: mockSetMessageDraft, |
||||
|
isQueueingMessages: false, |
||||
const inputElement = screen.getByPlaceholderText('Enter Message'); |
queueStatus: { free: 10 }, |
||||
const formElement = screen.getByRole('form'); |
hardware: { |
||||
const newMessage = 'New broadcast msg'; |
myNodeNum: 1234567890, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
fireEvent.change(inputElement, { target: { value: newMessage } }); |
render(<MessageInput {...mockProps} />); |
||||
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage); |
|
||||
|
|
||||
fireEvent.submit(formElement); |
const inputField = screen.getByRole('textbox'); |
||||
|
|
||||
expect(mockOnSend).toHaveBeenCalledWith(newMessage); |
expect(inputField).toHaveValue('Existing draft'); |
||||
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest); |
|
||||
}); |
}); |
||||
}); |
}); |
||||
@ -1,126 +1,77 @@ |
|||||
import React from 'react'; |
|
||||
import { cn } from "@core/utils/cn.ts"; |
import { cn } from "@core/utils/cn.ts"; |
||||
import { type LucideIcon } from "lucide-react"; |
import { AlignLeftIcon, type LucideIcon } from "lucide-react"; |
||||
import Footer from "@components/UI/Footer.tsx"; |
import Footer from "@components/UI/Footer.tsx"; |
||||
import { Spinner } from "@components/UI/Spinner.tsx"; |
import { Spinner } from "@components/UI/Spinner.tsx"; |
||||
import { ErrorBoundary } from "react-error-boundary"; |
import { ErrorBoundary } from "react-error-boundary"; |
||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx"; |
import { ErrorPage } from "@components/UI/ErrorPage.tsx"; |
||||
|
|
||||
export interface ActionItem { |
|
||||
key: string; |
|
||||
icon: LucideIcon; |
|
||||
iconClasses?: string; |
|
||||
onClick: () => void; |
|
||||
disabled?: boolean; |
|
||||
isLoading?: boolean; |
|
||||
ariaLabel?: string; |
|
||||
} |
|
||||
|
|
||||
export interface PageLayoutProps { |
export interface PageLayoutProps { |
||||
label: string; |
label: string; |
||||
actions?: ActionItem[]; |
|
||||
children: React.ReactNode; |
|
||||
leftBar?: React.ReactNode; |
|
||||
rightBar?: React.ReactNode; |
|
||||
noPadding?: boolean; |
noPadding?: boolean; |
||||
leftBarClassName?: string; |
children: React.ReactNode; |
||||
rightBarClassName?: string; |
className?: string; |
||||
topBarClassName?: string; |
actions?: { |
||||
contentClassName?: string; |
icon: LucideIcon; |
||||
|
iconClasses?: string; |
||||
|
onClick: () => void; |
||||
|
disabled?: boolean; |
||||
|
isLoading?: boolean; |
||||
|
}[]; |
||||
} |
} |
||||
|
|
||||
export const PageLayout = ({ |
export const PageLayout = ({ |
||||
label, |
label, |
||||
|
noPadding, |
||||
actions, |
actions, |
||||
|
className, |
||||
children, |
children, |
||||
leftBar, |
|
||||
rightBar, |
|
||||
noPadding, |
|
||||
leftBarClassName, |
|
||||
rightBarClassName, |
|
||||
topBarClassName, |
|
||||
contentClassName |
|
||||
}: PageLayoutProps) => { |
}: PageLayoutProps) => { |
||||
return ( |
return ( |
||||
<ErrorBoundary FallbackComponent={ErrorPage}> |
<ErrorBoundary FallbackComponent={ErrorPage}> |
||||
<div className="flex flex-1 bg-background text-foreground overflow-hidden"> |
<div className="relative flex h-full w-full flex-col"> |
||||
{/* Left Sidebar */} |
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4"> |
||||
{leftBar && ( |
<button |
||||
<aside |
type="button" |
||||
className={cn( |
className="pl-4 transition-all hover:text-accent md:hidden" |
||||
"px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ", |
|
||||
leftBarClassName |
|
||||
)} |
|
||||
> |
|
||||
{leftBar} |
|
||||
</aside> |
|
||||
)} |
|
||||
|
|
||||
<div className="flex flex-1 flex-col min-w-0"> |
|
||||
{/* Header */} |
|
||||
<header |
|
||||
className={cn( |
|
||||
"flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700", |
|
||||
topBarClassName |
|
||||
)} |
|
||||
> |
> |
||||
{/* Header Content */} |
<AlignLeftIcon /> |
||||
<div className="flex flex-1 items-center justify-between min-w-0"> |
</button> |
||||
<span className="text-lg font-medium text-foreground truncate px-2"> |
<div className="flex flex-1 items-center justify-between px-4 md:px-0"> |
||||
{label} |
<div className="flex w-full items-center"> |
||||
</span> |
<span className="w-full text-lg font-medium">{label}</span> |
||||
<div className="flex items-center space-x-3 md:space-x-4 shrink-0"> |
<div className="flex justify-end space-x-4"> |
||||
{actions?.map((action) => ( |
{actions?.map((action) => ( |
||||
<button |
<button |
||||
key={action.key} |
key={action.icon.displayName} |
||||
type="button" |
type="button" |
||||
disabled={action.disabled || action.isLoading} |
disabled={action?.disabled} |
||||
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed" |
className="transition-all hover:text-accent" |
||||
onClick={action.onClick} |
onClick={action.onClick} |
||||
aria-label={action.ariaLabel || `Action ${action.key}`} |
|
||||
aria-disabled={action.disabled} |
|
||||
aria-busy={action.isLoading} |
|
||||
> |
> |
||||
<div className="mr-6"> |
{action?.isLoading ? <Spinner /> : ( |
||||
{action.isLoading ? ( |
<action.icon |
||||
<Spinner size="md" /> |
className={action.iconClasses} |
||||
) : ( |
aria-disabled={action.disabled} |
||||
<action.icon |
/> |
||||
className={cn("h-5 w-5", action.iconClasses)} |
)} |
||||
/> |
|
||||
)} |
|
||||
</div> |
|
||||
</button> |
</button> |
||||
))} |
))} |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
</header> |
</div> |
||||
|
</div> |
||||
<main |
<div |
||||
className={cn( |
className={cn( |
||||
"flex-1 flex flex-col", |
"flex h-full w-full flex-col overflow-y-auto", |
||||
"overflow-hidden", |
!noPadding && "pl-3 pr-3 ", |
||||
!noPadding && "px-2", |
className |
||||
contentClassName |
)} |
||||
)} |
> |
||||
> |
{children} |
||||
{children} |
|
||||
</main> |
|
||||
<Footer /> |
<Footer /> |
||||
</div> |
</div> |
||||
|
|
||||
{/* Right Sidebar */} |
|
||||
{rightBar && ( |
|
||||
<aside |
|
||||
className={cn( |
|
||||
"w-48 lg:w-[270px] shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden", |
|
||||
rightBarClassName |
|
||||
)} |
|
||||
> |
|
||||
{rightBar} |
|
||||
</aside> |
|
||||
)} |
|
||||
</div> |
</div> |
||||
</ErrorBoundary> |
</ErrorBoundary> |
||||
); |
); |
||||
}; |
}; |
||||
|
|||||
@ -1,241 +1,148 @@ |
|||||
import React from "react"; |
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; |
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; |
||||
|
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; |
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; |
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; |
||||
import { useDevice } from "@core/stores/deviceStore.ts"; |
import { useDevice } from "@core/stores/deviceStore.ts"; |
||||
import type { Page } from "@core/stores/deviceStore.ts"; |
import type { Page } from "@core/stores/deviceStore.ts"; |
||||
import { Spinner } from "@components/UI/Spinner.tsx"; |
import { Spinner } from "@components/UI/Spinner.tsx"; |
||||
import { Avatar } from "@components/UI/Avatar.tsx"; |
|
||||
|
|
||||
import { |
import { |
||||
CircleChevronLeft, |
BatteryMediumIcon, |
||||
CpuIcon, |
CpuIcon, |
||||
|
EditIcon, |
||||
LayersIcon, |
LayersIcon, |
||||
type LucideIcon, |
type LucideIcon, |
||||
MapIcon, |
MapIcon, |
||||
MessageSquareIcon, |
MessageSquareIcon, |
||||
PenLine, |
|
||||
SearchIcon, |
|
||||
SettingsIcon, |
SettingsIcon, |
||||
|
SidebarCloseIcon, |
||||
|
SidebarOpenIcon, |
||||
UsersIcon, |
UsersIcon, |
||||
ZapIcon, |
ZapIcon, |
||||
} from "lucide-react"; |
} from "lucide-react"; |
||||
import { cn } from "@core/utils/cn.ts"; |
import { useState } from "react"; |
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx"; |
|
||||
import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; |
|
||||
import { useAppStore } from "@core/stores/appStore.ts"; |
|
||||
import BatteryStatus from "@components/BatteryStatus.tsx"; |
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; |
|
||||
|
|
||||
export interface SidebarProps { |
export interface SidebarProps { |
||||
children?: React.ReactNode; |
children?: React.ReactNode; |
||||
} |
} |
||||
|
|
||||
interface NavLink { |
|
||||
name: string; |
|
||||
icon: LucideIcon; |
|
||||
page: Page; |
|
||||
} |
|
||||
|
|
||||
const CollapseToggleButton = () => { |
|
||||
const { isCollapsed, toggleSidebar } = useSidebar(); |
|
||||
const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar"; |
|
||||
|
|
||||
return ( |
|
||||
<button |
|
||||
type="button" |
|
||||
aria-label={buttonLabel} |
|
||||
onClick={toggleSidebar} |
|
||||
className={cn( |
|
||||
'absolute top-20 right-0 z-10 p-0.5 rounded-full transform translate-x-1/2', |
|
||||
'transition-colors duration-300 ease-in-out', |
|
||||
'border border-slate-300 dark:border-slate-200', |
|
||||
'text-slate-500 dark:text-slate-200 hover:text-slate-400 dark:hover:text-slate-400', |
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent transition-transform' |
|
||||
)} |
|
||||
> |
|
||||
<CircleChevronLeft |
|
||||
size={24} |
|
||||
className={cn( |
|
||||
'transition-transform duration-300 ease-in-out', |
|
||||
isCollapsed && 'rotate-180' |
|
||||
)} |
|
||||
/> |
|
||||
</button> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
export const Sidebar = ({ children }: SidebarProps) => { |
export const Sidebar = ({ children }: SidebarProps) => { |
||||
const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice(); |
const { hardware, nodes, metadata } = useDevice(); |
||||
const { setCommandPaletteOpen } = useAppStore(); |
const myNode = nodes.get(hardware.myNodeNum); |
||||
const myNode = getNode(hardware.myNodeNum); |
|
||||
const { isCollapsed } = useSidebar(); |
|
||||
const myMetadata = metadata.get(0); |
const myMetadata = metadata.get(0); |
||||
|
const { activePage, setActivePage, setDialogOpen } = useDevice(); |
||||
|
const [showSidebar, setShowSidebar] = useState<boolean>(true); |
||||
|
|
||||
|
interface NavLink { |
||||
|
name: string; |
||||
|
icon: LucideIcon; |
||||
|
page: Page; |
||||
|
} |
||||
|
|
||||
const pages: NavLink[] = [ |
const pages: NavLink[] = [ |
||||
{ name: "Messages", icon: MessageSquareIcon, page: "messages" }, |
|
||||
{ name: "Map", icon: MapIcon, page: "map" }, |
|
||||
{ name: "Config", icon: SettingsIcon, page: "config" }, |
|
||||
{ name: "Channels", icon: LayersIcon, page: "channels" }, |
|
||||
{ |
{ |
||||
name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`, |
name: "Messages", |
||||
|
icon: MessageSquareIcon, |
||||
|
page: "messages", |
||||
|
}, |
||||
|
{ |
||||
|
name: "Map", |
||||
|
icon: MapIcon, |
||||
|
page: "map", |
||||
|
}, |
||||
|
{ |
||||
|
name: "Config", |
||||
|
icon: SettingsIcon, |
||||
|
page: "config", |
||||
|
}, |
||||
|
{ |
||||
|
name: "Channels", |
||||
|
icon: LayersIcon, |
||||
|
page: "channels", |
||||
|
}, |
||||
|
{ |
||||
|
name: `Nodes (${nodes.size - 1})`, |
||||
icon: UsersIcon, |
icon: UsersIcon, |
||||
page: "nodes", |
page: "nodes", |
||||
}, |
}, |
||||
]; |
]; |
||||
|
|
||||
return ( |
return showSidebar |
||||
<div |
? ( |
||||
className={cn( |
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-400"> |
||||
'relative border-slate-300 dark:border-slate-700', |
|
||||
'transition-all duration-300 ease-in-out flex-shrink-0', |
|
||||
isCollapsed ? 'w-24' : 'w-46 lg:w-64' |
|
||||
)} |
|
||||
> |
|
||||
<CollapseToggleButton /> |
|
||||
|
|
||||
<div |
|
||||
className={cn( |
|
||||
'h-14 flex mt-2 gap-2 items-center flex-shrink-0 transition-all duration-300 ease-in-out', |
|
||||
'border-b-[0.5px] border-slate-300 dark:border-slate-700', |
|
||||
isCollapsed && 'justify-center px-0' |
|
||||
|
|
||||
)} |
|
||||
> |
|
||||
<img |
|
||||
src="Logo.svg" |
|
||||
alt="Meshtastic Logo" |
|
||||
className="size-10 flex-shrink-0 rounded-xl" |
|
||||
/> |
|
||||
<h2 |
|
||||
className={cn( |
|
||||
'text-xl font-semibold text-gray-800 dark:text-gray-100 whitespace-nowrap', |
|
||||
'transition-all duration-300 ease-in-out', |
|
||||
isCollapsed |
|
||||
? 'opacity-0 max-w-0 invisible ml-0' |
|
||||
: 'opacity-100 max-w-xs visible ml-2' |
|
||||
)} |
|
||||
> |
|
||||
Meshtastic |
|
||||
</h2> |
|
||||
</div> |
|
||||
|
|
||||
<SidebarSection label="Navigation" className="mt-4 px-0"> |
|
||||
{pages.map((link) => ( |
|
||||
<SidebarButton |
|
||||
key={link.name} |
|
||||
label={link.name} |
|
||||
Icon={link.icon} |
|
||||
onClick={() => { |
|
||||
if (myNode !== undefined) { |
|
||||
setActivePage(link.page); |
|
||||
} |
|
||||
}} |
|
||||
active={link.page === activePage} |
|
||||
disabled={myNode === undefined} |
|
||||
/> |
|
||||
))} |
|
||||
</SidebarSection> |
|
||||
|
|
||||
<div className={cn( |
|
||||
'flex-1 min-h-0', |
|
||||
isCollapsed && 'overflow-hidden' |
|
||||
)} |
|
||||
> |
|
||||
{children} |
|
||||
</div> |
|
||||
|
|
||||
<div className="pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 flex-shrink-0"> |
|
||||
{myNode === undefined ? ( |
{myNode === undefined ? ( |
||||
<div className="flex flex-col items-center justify-center py-6"> |
<div className="flex flex-col items-center justify-center px-8 py-6"> |
||||
<Spinner /> |
<Spinner /> |
||||
<Subtle |
<Subtle className="mt-2">Loading device info...</Subtle> |
||||
className={cn( |
|
||||
'mt-4 transition-opacity duration-300', |
|
||||
isCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible' |
|
||||
)} |
|
||||
> |
|
||||
Loading... |
|
||||
</Subtle> |
|
||||
</div> |
</div> |
||||
) : ( |
) : ( |
||||
<> |
<> |
||||
<div |
<div className="flex justify-between px-8 pt-6"> |
||||
className={cn( |
<div> |
||||
'flex place-items-center gap-2', |
<span className="text-lg font-medium"> |
||||
isCollapsed && 'justify-center' |
{myNode.user?.shortName ?? "UNK"} |
||||
)} |
</span> |
||||
> |
<Subtle>{myNode.user?.longName ?? "UNK"}</Subtle> |
||||
<Avatar |
|
||||
text={myNode.user?.shortName ?? myNode.num.toString()} |
|
||||
className={cn("flex-shrink-0 ml-2", |
|
||||
isCollapsed && "ml-0", |
|
||||
)} |
|
||||
size="sm" |
|
||||
/> |
|
||||
<p |
|
||||
className={cn( |
|
||||
'max-w-[20ch] text-wrap text-sm font-medium', |
|
||||
'transition-all duration-300 ease-in-out overflow-hidden', |
|
||||
isCollapsed |
|
||||
? 'opacity-0 max-w-0 invisible' |
|
||||
: 'opacity-100 max-w-full visible' |
|
||||
)} |
|
||||
> |
|
||||
{myNode.user?.longName} |
|
||||
</p> |
|
||||
</div> |
|
||||
|
|
||||
<div |
|
||||
className={cn( |
|
||||
'flex flex-col gap-0.5 ml-2 mt-2', |
|
||||
'transition-all duration-300 ease-in-out', |
|
||||
isCollapsed |
|
||||
? 'opacity-0 max-w-0 h-0 invisible' |
|
||||
: 'opacity-100 max-w-xs h-auto visible' |
|
||||
)} |
|
||||
> |
|
||||
<div className="inline-flex gap-2"> |
|
||||
<BatteryStatus deviceMetrics={myNode.deviceMetrics} /> |
|
||||
</div> |
|
||||
<div className="inline-flex gap-2"> |
|
||||
<ZapIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> |
|
||||
<Subtle>{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts</Subtle> |
|
||||
</div> |
</div> |
||||
<div className="inline-flex gap-2"> |
|
||||
<CpuIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> |
|
||||
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle> |
|
||||
</div> |
|
||||
</div> |
|
||||
<div |
|
||||
className={cn( |
|
||||
'flex items-center flex-shrink-0 ml-2', |
|
||||
'transition-all duration-300 ease-in-out', |
|
||||
isCollapsed |
|
||||
? 'opacity-0 max-w-0 invisible pointer-events-none' |
|
||||
: 'opacity-100 max-w-xs visible' |
|
||||
)} |
|
||||
> |
|
||||
<button |
<button |
||||
type="button" |
type="button" |
||||
aria-label="Edit device name" |
className="transition-all hover:text-accent" |
||||
className="p-1 rounded transition-colors hover:text-accent" |
|
||||
onClick={() => setDialogOpen("deviceName", true)} |
onClick={() => setDialogOpen("deviceName", true)} |
||||
> |
> |
||||
<PenLine size={22} /> |
<EditIcon size={16} /> |
||||
</button> |
</button> |
||||
<ThemeSwitcher /> |
<button type="button" onClick={() => setShowSidebar(false)}> |
||||
<button |
<SidebarCloseIcon size={24} /> |
||||
type="button" |
|
||||
className="transition-all hover:text-accent" |
|
||||
onClick={() => setCommandPaletteOpen(true)} |
|
||||
> |
|
||||
<SearchIcon /> |
|
||||
</button> |
</button> |
||||
</div> |
</div> |
||||
|
<div className="px-8 pb-6"> |
||||
|
<div className="flex items-center"> |
||||
|
<BatteryMediumIcon size={24} viewBox="0 0 28 24" /> |
||||
|
<Subtle> |
||||
|
{myNode.deviceMetrics?.batteryLevel |
||||
|
? myNode.deviceMetrics.batteryLevel > 100 |
||||
|
? "Charging" |
||||
|
: `${myNode.deviceMetrics.batteryLevel}%` |
||||
|
: "UNK"} |
||||
|
</Subtle> |
||||
|
</div> |
||||
|
<div className="flex items-center"> |
||||
|
<ZapIcon size={24} viewBox="0 0 36 24" /> |
||||
|
<Subtle> |
||||
|
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts |
||||
|
</Subtle> |
||||
|
</div> |
||||
|
<div className="flex items-center"> |
||||
|
<CpuIcon size={24} viewBox="0 0 36 24" /> |
||||
|
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle> |
||||
|
</div> |
||||
|
</div> |
||||
</> |
</> |
||||
)} |
)} |
||||
|
|
||||
|
<SidebarSection label="Navigation"> |
||||
|
{pages.map((link) => ( |
||||
|
<SidebarButton |
||||
|
key={link.name} |
||||
|
label={link.name} |
||||
|
Icon={link.icon} |
||||
|
onClick={() => { |
||||
|
if (myNode !== undefined) { |
||||
|
setActivePage(link.page); |
||||
|
} |
||||
|
}} |
||||
|
active={link.page === activePage} |
||||
|
disabled={myNode === undefined} |
||||
|
/> |
||||
|
))} |
||||
|
</SidebarSection> |
||||
|
{children} |
||||
|
</div> |
||||
|
) |
||||
|
: ( |
||||
|
<div className="px-1 pt-8 border-r-[0.5px] border-slate-700"> |
||||
|
<button type="button" onClick={() => setShowSidebar(true)}> |
||||
|
<SidebarOpenIcon size={24} /> |
||||
|
</button> |
||||
</div> |
</div> |
||||
</div> |
); |
||||
); |
}; |
||||
}; |
|
||||
|
|||||
@ -1,185 +1,73 @@ |
|||||
import * as React from "react"; |
import * as React from "react"; |
||||
|
|
||||
import { cn } from "@core/utils/cn.ts"; |
import { cn } from "@core/utils/cn.ts"; |
||||
import { cva, type VariantProps } from "class-variance-authority"; |
import { cva, type VariantProps } from "class-variance-authority"; |
||||
import { Check, Copy, Eye, EyeOff, X, type LucideIcon } from "lucide-react"; |
import type { LucideIcon } from "lucide-react"; |
||||
import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts"; |
|
||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; |
|
||||
|
|
||||
const inputVariants = cva( |
const inputVariants = cva( |
||||
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:bg-transparet dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600", |
"flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:open-dialog:text-slate-900", |
||||
{ |
{ |
||||
variants: { |
variants: { |
||||
variant: { |
variant: { |
||||
default: "border-slate-300 dark:border-slate-500", |
default: "border-slate-300 dark:border-slate-700", |
||||
invalid: |
invalid: "border-red-500 dark:border-red-500", |
||||
"border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-500", |
|
||||
}, |
}, |
||||
}, |
}, |
||||
defaultVariants: { |
defaultVariants: { |
||||
variant: "default", |
variant: "default", |
||||
}, |
}, |
||||
} |
}, |
||||
); |
); |
||||
|
|
||||
type InputActionType = { |
|
||||
id: string; |
|
||||
icon: LucideIcon; |
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; |
|
||||
ariaLabel: string; |
|
||||
tooltip?: string |
|
||||
condition?: boolean; |
|
||||
}; |
|
||||
|
|
||||
export interface InputProps |
export interface InputProps |
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "prefix" | "suffix">, |
extends |
||||
|
React.InputHTMLAttributes<HTMLInputElement>, |
||||
VariantProps<typeof inputVariants> { |
VariantProps<typeof inputVariants> { |
||||
prefix?: React.ReactNode; |
prefix?: string; |
||||
suffix?: React.ReactNode; |
suffix?: string; |
||||
showPasswordToggle?: boolean; |
action?: { |
||||
showCopyButton?: boolean; |
icon: LucideIcon; |
||||
showClearButton?: boolean; |
onClick: () => void; |
||||
containerClassName?: string; |
}; |
||||
} |
} |
||||
|
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>( |
const Input = React.forwardRef<HTMLInputElement, InputProps>( |
||||
( |
({ className, value, variant, prefix, suffix, action, ...props }, ref) => { |
||||
{ |
|
||||
className, |
|
||||
containerClassName, |
|
||||
variant, |
|
||||
type = "text", |
|
||||
prefix, |
|
||||
suffix, |
|
||||
showPasswordToggle, |
|
||||
showCopyButton, |
|
||||
showClearButton, |
|
||||
value, |
|
||||
onChange, |
|
||||
...props |
|
||||
}, |
|
||||
ref |
|
||||
) => { |
|
||||
const { isVisible, toggleVisibility } = usePasswordVisibilityToggle(); |
|
||||
const { copy, isCopied } = useCopyToClipboard({ timeout: 1500 }); |
|
||||
|
|
||||
const potentialActions: InputActionType[] = [ |
|
||||
{ |
|
||||
id: "clear-input", |
|
||||
icon: X, |
|
||||
onClick: (e) => { |
|
||||
e.stopPropagation(); |
|
||||
if (onChange) { |
|
||||
const event = { |
|
||||
target: { value: "" }, |
|
||||
currentTarget: { value: "" }, |
|
||||
} as React.ChangeEvent<HTMLInputElement>; |
|
||||
onChange(event); |
|
||||
} |
|
||||
if (ref && typeof ref !== "function" && ref.current) { |
|
||||
ref.current.focus(); |
|
||||
} |
|
||||
}, |
|
||||
ariaLabel: "Clear input", |
|
||||
tooltip: "Clear input", |
|
||||
condition: !!showClearButton && !!value, |
|
||||
}, |
|
||||
{ |
|
||||
id: "toggle-visibility", |
|
||||
icon: isVisible ? EyeOff : Eye, |
|
||||
onClick: (e) => { |
|
||||
e.stopPropagation(); |
|
||||
toggleVisibility(); |
|
||||
}, |
|
||||
ariaLabel: isVisible ? "Hide password" : "Show password", |
|
||||
tooltip: isVisible ? "Hide password" : "Show password", |
|
||||
condition: !!showPasswordToggle && type === "password", |
|
||||
}, |
|
||||
{ |
|
||||
id: "copy-value", |
|
||||
icon: isCopied ? Check : Copy, |
|
||||
onClick: (e) => { |
|
||||
e.stopPropagation(); |
|
||||
if (value !== undefined && value !== null) { |
|
||||
copy(String(value)); |
|
||||
} |
|
||||
}, |
|
||||
ariaLabel: isCopied ? "Copied!" : "Copy to clipboard", |
|
||||
tooltip: isCopied ? "Copied!" : "Copy to clipboard", |
|
||||
condition: !!showCopyButton, |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
const actions = potentialActions.filter(action => action.condition); |
|
||||
|
|
||||
const inputType = |
|
||||
showPasswordToggle ? (isVisible ? "text" : "password") : type; |
|
||||
|
|
||||
const hasPrefix = !!prefix; |
|
||||
const hasSuffix = !!suffix; |
|
||||
const hasActions = actions.length > 0; |
|
||||
|
|
||||
const inputClassName = cn( |
|
||||
inputVariants({ variant }), |
|
||||
hasActions && !hasSuffix && "pr-10", |
|
||||
hasPrefix && "rounded-l-none", |
|
||||
className |
|
||||
); |
|
||||
|
|
||||
return ( |
return ( |
||||
<div className={cn("relative flex w-full items-stretch", containerClassName)}> |
<div className="relative w-full"> |
||||
{prefix && ( |
{prefix && ( |
||||
<span className="inline-flex items-center rounded-l-md border border-r-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-200 dark:text-slate-700"> |
<label className="inline-flex items-center rounded-l-md bg-slate-100/80 px-3 font-mono text-sm text-slate-600"> |
||||
{prefix} |
{prefix} |
||||
</span> |
</label> |
||||
)} |
)} |
||||
|
|
||||
<input |
<input |
||||
type={inputType === "password" && isVisible ? "text" : inputType} |
className={cn( |
||||
className={inputClassName} |
action && "pr-8", |
||||
ref={ref} |
inputVariants({ variant }), |
||||
|
className, |
||||
|
)} |
||||
value={value} |
value={value} |
||||
onChange={onChange} |
ref={ref} |
||||
{...props} |
{...props} |
||||
/> |
/> |
||||
|
{suffix && ( |
||||
<div className="absolute right-0 top-0 flex h-full items-stretch"> |
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-9 font-mono text-slate-500 dark:text-slate-900"> |
||||
{suffix && ( |
<span className="text-slate-100/40 sm:text-sm">{suffix}</span> |
||||
<span className={cn( |
</div> |
||||
"inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300", |
)} |
||||
!hasActions && "rounded-r-md" |
{action && ( |
||||
)}> |
<button |
||||
{suffix} |
type="button" |
||||
</span> |
className="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-500 hover:text-slate-400 focus:outline-hidden " |
||||
)} |
onClick={action.onClick} |
||||
|
> |
||||
{hasActions && ( |
<action.icon size={20} /> |
||||
<div className={cn( |
</button> |
||||
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700", |
)} |
||||
!hasSuffix && "rounded-r-md", |
|
||||
"bg-white dark:bg-slate-800" |
|
||||
)}> |
|
||||
{actions.map((action) => ( |
|
||||
<button |
|
||||
key={action.id} |
|
||||
type="button" |
|
||||
className={cn( |
|
||||
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md", |
|
||||
action.id === 'copy-value' && isCopied && "text-green-600 dark:text-green-500" |
|
||||
)} |
|
||||
onClick={action.onClick} |
|
||||
aria-label={action.ariaLabel} |
|
||||
title={action.tooltip || action.ariaLabel} |
|
||||
> |
|
||||
<action.icon size={18} aria-hidden="true" /> |
|
||||
</button> |
|
||||
))} |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
</div> |
</div> |
||||
); |
); |
||||
} |
}, |
||||
); |
); |
||||
Input.displayName = "Input"; |
Input.displayName = "Input"; |
||||
|
|
||||
export { Input, inputVariants }; |
export { Input, inputVariants }; |
||||
|
|||||
@ -1,81 +0,0 @@ |
|||||
import React from "react"; |
|
||||
import { Button } from "@components/UI/Button.tsx"; |
|
||||
import type { LucideIcon } from "lucide-react"; |
|
||||
import { cn } from "@core/utils/cn.ts"; |
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx"; |
|
||||
|
|
||||
export interface SidebarButtonProps { |
|
||||
label: string; |
|
||||
count?: number; |
|
||||
active?: boolean; |
|
||||
Icon?: LucideIcon; |
|
||||
children?: React.ReactNode; |
|
||||
onClick?: () => void; |
|
||||
disabled?: boolean; |
|
||||
preventCollapse?: boolean; |
|
||||
} |
|
||||
|
|
||||
export const SidebarButton = ({ |
|
||||
label, |
|
||||
active, |
|
||||
Icon, |
|
||||
count, |
|
||||
children, |
|
||||
onClick, |
|
||||
disabled = false, |
|
||||
preventCollapse = false, |
|
||||
}: SidebarButtonProps) => { |
|
||||
const { isCollapsed: isSidebarCollapsed } = useSidebar(); |
|
||||
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse; |
|
||||
|
|
||||
return ( |
|
||||
<Button |
|
||||
onClick={onClick} |
|
||||
variant={active ? "subtle" : "ghost"} |
|
||||
size="sm" |
|
||||
className={cn( |
|
||||
"flex w-full items-center text-wrap", |
|
||||
isButtonCollapsed |
|
||||
? 'justify-center gap-0 px-2 h-9' |
|
||||
: 'justify-start gap-2 min-h-9' |
|
||||
)} |
|
||||
disabled={disabled} |
|
||||
> |
|
||||
{Icon && ( |
|
||||
<Icon |
|
||||
size={isButtonCollapsed ? 20 : 18} |
|
||||
className="flex-shrink-0" |
|
||||
/> |
|
||||
)} |
|
||||
|
|
||||
{children} |
|
||||
|
|
||||
<span |
|
||||
className={cn( |
|
||||
'flex flex-wrap justify-start text-left text-wrap break-all', |
|
||||
'min-w-0', |
|
||||
'px-1', |
|
||||
'transition-all duration-300 ease-in-out', |
|
||||
isButtonCollapsed |
|
||||
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden' |
|
||||
: 'opacity-100 max-w-full visible flex-1 whitespace-normal' |
|
||||
)} |
|
||||
> |
|
||||
{label} |
|
||||
</span> |
|
||||
|
|
||||
{!isButtonCollapsed && !active && count && count > 0 && ( |
|
||||
<div |
|
||||
className={cn( |
|
||||
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600", |
|
||||
"flex-shrink-0", |
|
||||
"transition-opacity duration-300 ease-in-out", |
|
||||
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible' |
|
||||
)} |
|
||||
> |
|
||||
{count} |
|
||||
</div> |
|
||||
)} |
|
||||
</Button> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,42 +1,19 @@ |
|||||
import React from "react"; |
import { Heading } from "../Typography/Heading.tsx"; |
||||
import { cn } from "@core/utils/cn.ts"; |
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx"; |
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx"; |
|
||||
|
|
||||
interface SidebarSectionProps { |
export interface SidebarSectionProps { |
||||
label: string; |
label: string; |
||||
|
subheader?: string; |
||||
children: React.ReactNode; |
children: React.ReactNode; |
||||
className?: string; |
|
||||
} |
} |
||||
|
|
||||
export const SidebarSection = ({ |
export const SidebarSection = ({ |
||||
label, |
label: title, |
||||
children, |
children, |
||||
className, |
}: SidebarSectionProps) => ( |
||||
}: SidebarSectionProps) => { |
<div className="px-4 py-2"> |
||||
const { isCollapsed } = useSidebar(); |
<Heading as="h4" className="mb-3 ml-2"> |
||||
return ( |
{title} |
||||
<div className={cn( |
</Heading> |
||||
"py-2", |
<div className="space-y-1">{children}</div> |
||||
isCollapsed ? 'px-0' : 'px-4', |
</div> |
||||
className, |
); |
||||
)}> |
|
||||
|
|
||||
<Heading as="h3" className={cn( |
|
||||
'mb-2', |
|
||||
'uppercase tracking-wider text-md', |
|
||||
'transition-all duration-300 ease-in-out', |
|
||||
'whitespace-nowrap overflow-hidden', |
|
||||
isCollapsed |
|
||||
? 'opacity-0 max-w-0 h-0 invisible px-0 mb-0' |
|
||||
: 'opacity-100 max-w-xs h-auto visible px-1 mb-1' |
|
||||
)}> |
|
||||
{label} |
|
||||
</Heading> |
|
||||
|
|
||||
<div className="space-y-0.5"> |
|
||||
{children} |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
|
|||||
@ -1,81 +1,32 @@ |
|||||
import React from "react"; |
|
||||
import { Button } from "@components/UI/Button.tsx"; |
import { Button } from "@components/UI/Button.tsx"; |
||||
import type { LucideIcon } from "lucide-react"; |
import type { LucideIcon } from "lucide-react"; |
||||
import { cn } from "@core/utils/cn.ts"; |
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx"; |
|
||||
|
|
||||
export interface SidebarButtonProps { |
export interface SidebarButtonProps { |
||||
label: string; |
label: string; |
||||
count?: number; |
|
||||
active?: boolean; |
active?: boolean; |
||||
Icon?: LucideIcon; |
Icon?: LucideIcon; |
||||
children?: React.ReactNode; |
element?; |
||||
onClick?: () => void; |
onClick?: () => void; |
||||
disabled?: boolean; |
disabled?: boolean; |
||||
preventCollapse?: boolean; |
|
||||
} |
} |
||||
|
|
||||
export const SidebarButton = ({ |
export const SidebarButton = ({ |
||||
label, |
label, |
||||
active, |
active, |
||||
Icon, |
Icon, |
||||
count, |
element, |
||||
children, |
|
||||
onClick, |
onClick, |
||||
disabled = false, |
disabled = false, |
||||
preventCollapse = false, |
}: SidebarButtonProps) => ( |
||||
}: SidebarButtonProps) => { |
<Button |
||||
const { isCollapsed: isSidebarCollapsed } = useSidebar(); |
onClick={onClick} |
||||
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse; |
variant={active ? "subtle" : "ghost"} |
||||
|
size="sm" |
||||
return ( |
className="flex gap-2 w-full" |
||||
<Button |
disabled={disabled} |
||||
onClick={onClick} |
> |
||||
variant={active ? "subtle" : "ghost"} |
{Icon && <Icon size={16} />} |
||||
size="sm" |
{element && element} |
||||
className={cn( |
<span className="flex flex-1 justify-start shrink-0">{label}</span> |
||||
"flex w-full items-center text-wrap", |
</Button> |
||||
isButtonCollapsed |
); |
||||
? 'justify-center gap-0 px-2 h-9' |
|
||||
: 'justify-start gap-2 min-h-9' |
|
||||
)} |
|
||||
disabled={disabled} |
|
||||
> |
|
||||
{Icon && ( |
|
||||
<Icon |
|
||||
size={isButtonCollapsed ? 20 : 18} |
|
||||
className="flex-shrink-0" |
|
||||
/> |
|
||||
)} |
|
||||
|
|
||||
{children} |
|
||||
|
|
||||
<span |
|
||||
className={cn( |
|
||||
'flex flex-wrap justify-start text-left text-wrap break-all', |
|
||||
'min-w-0', |
|
||||
'px-1', |
|
||||
'transition-all duration-300 ease-in-out', |
|
||||
isButtonCollapsed |
|
||||
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden' |
|
||||
: 'opacity-100 max-w-full visible flex-1 whitespace-normal' |
|
||||
)} |
|
||||
> |
|
||||
{label} |
|
||||
</span> |
|
||||
|
|
||||
{!isButtonCollapsed && !active && count && count > 0 && ( |
|
||||
<div |
|
||||
className={cn( |
|
||||
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600", |
|
||||
"flex-shrink-0", |
|
||||
"transition-opacity duration-300 ease-in-out", |
|
||||
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible' |
|
||||
)} |
|
||||
> |
|
||||
{count} |
|
||||
</div> |
|
||||
)} |
|
||||
</Button> |
|
||||
); |
|
||||
}; |
|
||||
|
|||||
@ -1,87 +0,0 @@ |
|||||
import { useId, useState } from "react"; |
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"; |
|
||||
import { cn } from "@core/utils/cn.ts"; |
|
||||
|
|
||||
export interface SliderProps { |
|
||||
value: number[]; |
|
||||
step?: number; |
|
||||
min?: number; |
|
||||
max: number; |
|
||||
onValueChange?: (value: number[]) => void; |
|
||||
onValueCommit?: (value: number[]) => void; |
|
||||
disabled?: boolean; |
|
||||
className?: string; |
|
||||
trackClassName?: string; |
|
||||
rangeClassName?: string; |
|
||||
thumbClassName?: string; |
|
||||
} |
|
||||
|
|
||||
export function Slider({ |
|
||||
value, |
|
||||
step = 1, |
|
||||
min = 0, |
|
||||
max, |
|
||||
onValueChange, |
|
||||
onValueCommit, |
|
||||
disabled = false, |
|
||||
className, |
|
||||
trackClassName, |
|
||||
rangeClassName, |
|
||||
thumbClassName, |
|
||||
...props |
|
||||
}: SliderProps) { |
|
||||
const [internalValue, setInternalValue] = useState<number[]>(value); |
|
||||
const isControlled = value !== undefined; |
|
||||
const currentValue = isControlled ? value! : internalValue; |
|
||||
const id = useId(); |
|
||||
|
|
||||
const handleValueChange = (newValue: number[]) => { |
|
||||
if (!isControlled) setInternalValue(newValue); |
|
||||
onValueChange?.(newValue); |
|
||||
}; |
|
||||
|
|
||||
const handleValueCommit = (newValue: number[]) => { |
|
||||
onValueCommit?.(newValue); |
|
||||
}; |
|
||||
|
|
||||
return ( |
|
||||
<SliderPrimitive.Root |
|
||||
className={cn( |
|
||||
"relative flex items-center select-none touch-none", |
|
||||
className, |
|
||||
)} |
|
||||
value={currentValue} |
|
||||
step={step} |
|
||||
min={min} |
|
||||
max={max} |
|
||||
disabled={disabled} |
|
||||
onValueChange={handleValueChange} |
|
||||
onValueCommit={handleValueCommit} |
|
||||
{...props} |
|
||||
> |
|
||||
<SliderPrimitive.Track |
|
||||
className={cn( |
|
||||
"relative h-2 flex-1 rounded-full bg-slate-200", |
|
||||
trackClassName, |
|
||||
)} |
|
||||
> |
|
||||
<SliderPrimitive.Range |
|
||||
className={cn( |
|
||||
"absolute h-full rounded-full bg-blue-500", |
|
||||
rangeClassName, |
|
||||
)} |
|
||||
/> |
|
||||
</SliderPrimitive.Track> |
|
||||
{currentValue.map((_, i) => ( |
|
||||
<SliderPrimitive.Thumb |
|
||||
key={`${id}-thumb-${i}`} |
|
||||
className={cn( |
|
||||
"block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md", |
|
||||
thumbClassName, |
|
||||
)} |
|
||||
aria-label={`Thumb ${i + 1}`} |
|
||||
/> |
|
||||
))} |
|
||||
</SliderPrimitive.Root> |
|
||||
); |
|
||||
} |
|
||||
@ -1,40 +0,0 @@ |
|||||
import { create } from "@bufbuild/protobuf"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
class NodeInfoFactory { |
|
||||
private static createDefaultUser(num: number): Protobuf.Mesh.User { |
|
||||
const userIdHex = num.toString(16).toUpperCase().padStart(2, '0'); |
|
||||
const userId = `!${userIdHex}`; |
|
||||
const last4 = userIdHex.slice(-4); |
|
||||
const longName = `Meshtastic ${last4}`; |
|
||||
const shortName = last4; |
|
||||
const hwModel = Protobuf.Mesh.HardwareModel.UNSET; |
|
||||
|
|
||||
return create(Protobuf.Mesh.UserSchema, { |
|
||||
id: userId, |
|
||||
longName: longName, |
|
||||
shortName: shortName, |
|
||||
hwModel: hwModel, |
|
||||
isLicensed: false, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
public static ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo { |
|
||||
if (!node) { |
|
||||
return node; |
|
||||
} |
|
||||
|
|
||||
if (!node.user) { |
|
||||
if (node.num === undefined || node.num === null) { |
|
||||
console.error(`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`, node); |
|
||||
return node; |
|
||||
} |
|
||||
|
|
||||
node.user = NodeInfoFactory.createDefaultUser(node.num); |
|
||||
} |
|
||||
|
|
||||
return node; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default NodeInfoFactory; |
|
||||
@ -1,51 +0,0 @@ |
|||||
import type { Types } from "@meshtastic/js"; |
|
||||
import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts"; |
|
||||
|
|
||||
class PacketToMessageDTO { |
|
||||
channel: Types.ChannelNumber; |
|
||||
to: number; |
|
||||
from: number; |
|
||||
date: number; // (timestamp ms)
|
|
||||
messageId: number; |
|
||||
state: MessageState; |
|
||||
message: string; |
|
||||
type: MessageType; |
|
||||
|
|
||||
constructor(data: Types.PacketMetadata<string>, nodeNum: number) { |
|
||||
this.channel = data.channel; |
|
||||
this.to = data.to; |
|
||||
this.from = data.from; |
|
||||
this.messageId = data.id; |
|
||||
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting; |
|
||||
this.message = data.data; |
|
||||
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast; |
|
||||
|
|
||||
let dateTimestamp = Date.now(); |
|
||||
if (data.rxTime instanceof Date) { |
|
||||
const timeValue = data.rxTime.getTime(); |
|
||||
|
|
||||
if (!isNaN(timeValue)) { |
|
||||
dateTimestamp = timeValue; |
|
||||
} |
|
||||
} |
|
||||
else if (data.rxTime != null) { |
|
||||
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`); |
|
||||
} |
|
||||
this.date = dateTimestamp; |
|
||||
} |
|
||||
|
|
||||
toMessage(): Message { |
|
||||
return { |
|
||||
channel: this.channel, |
|
||||
to: this.to, |
|
||||
from: this.from, |
|
||||
date: this.date, |
|
||||
messageId: this.messageId, |
|
||||
state: this.state, |
|
||||
message: this.message, |
|
||||
type: this.type, |
|
||||
}; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default PacketToMessageDTO; |
|
||||
@ -1,51 +0,0 @@ |
|||||
import { useCallback, useEffect, useRef, useState } from "react"; |
|
||||
|
|
||||
interface UseCopyToClipboardProps { |
|
||||
timeout?: number; |
|
||||
} |
|
||||
|
|
||||
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) { |
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false); |
|
||||
const timeoutRef = useRef<number | null>(null); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
return () => { |
|
||||
if (timeoutRef.current) { |
|
||||
globalThis.clearTimeout(timeoutRef.current); |
|
||||
} |
|
||||
}; |
|
||||
}, []); |
|
||||
|
|
||||
const copy = useCallback( |
|
||||
async (text: string) => { |
|
||||
if (!navigator?.clipboard) { |
|
||||
console.warn('Clipboard API not available'); |
|
||||
setIsCopied(false); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
if (timeoutRef.current) { |
|
||||
globalThis.clearTimeout(timeoutRef.current); |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
await navigator.clipboard.writeText(text); |
|
||||
setIsCopied(true); |
|
||||
|
|
||||
timeoutRef.current = globalThis.setTimeout(() => { |
|
||||
setIsCopied(false); |
|
||||
timeoutRef.current = null; |
|
||||
}, timeout); |
|
||||
|
|
||||
return true; |
|
||||
} catch (error) { |
|
||||
console.error('Failed to copy text to clipboard:', error); |
|
||||
setIsCopied(false); |
|
||||
return false; |
|
||||
} |
|
||||
}, |
|
||||
[timeout] |
|
||||
); |
|
||||
|
|
||||
return { isCopied, copy }; |
|
||||
} |
|
||||
@ -1,294 +0,0 @@ |
|||||
import { useCallback, useMemo, useState } from "react"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|
||||
|
|
||||
interface BooleanFilter { |
|
||||
key: string; |
|
||||
label: string; |
|
||||
group: string; |
|
||||
type: "boolean"; |
|
||||
predicate: (node: Protobuf.Mesh.NodeInfo, value: boolean) => boolean; |
|
||||
} |
|
||||
|
|
||||
interface RangeFilter { |
|
||||
key: string; |
|
||||
label: string; |
|
||||
group: string; |
|
||||
type: "range"; |
|
||||
bounds: [number, number]; |
|
||||
predicate: (node: Protobuf.Mesh.NodeInfo, value: [number, number]) => boolean; |
|
||||
} |
|
||||
|
|
||||
interface SearchFilter { |
|
||||
key: string; |
|
||||
label: string; |
|
||||
group: string; |
|
||||
type: "search"; |
|
||||
predicate: (node: Protobuf.Mesh.NodeInfo, value: string) => boolean; |
|
||||
} |
|
||||
|
|
||||
interface MultiFilter { |
|
||||
key: string; |
|
||||
label: string; |
|
||||
group: string; |
|
||||
type: "multi"; |
|
||||
options: string[]; |
|
||||
predicate: (node: Protobuf.Mesh.NodeInfo, value: string[]) => boolean; |
|
||||
} |
|
||||
|
|
||||
export type FilterConfig = |
|
||||
| BooleanFilter |
|
||||
| RangeFilter |
|
||||
| SearchFilter |
|
||||
| MultiFilter; |
|
||||
|
|
||||
export type FilterValueMap = { |
|
||||
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean |
|
||||
: C extends RangeFilter ? [number, number] |
|
||||
: C extends SearchFilter ? string |
|
||||
: C extends MultiFilter ? string[] |
|
||||
: never; |
|
||||
}; |
|
||||
|
|
||||
// Defines all node filters in this object
|
|
||||
export const filterConfigs: FilterConfig[] = [ |
|
||||
{ |
|
||||
key: "searchText", |
|
||||
label: "Node name/number", |
|
||||
group: "General", |
|
||||
type: "search", |
|
||||
predicate: (node, text: string) => { |
|
||||
if (!text) return true; |
|
||||
const shortName = node.user?.shortName?.toString().toLowerCase() ?? ""; |
|
||||
const longName = node.user?.longName?.toString().toLowerCase() ?? ""; |
|
||||
const nodeNum = node.num?.toString() ?? ""; |
|
||||
const nodeNumHex = numberToHexUnpadded(node.num) ?? ""; |
|
||||
const search = text.toLowerCase(); |
|
||||
return shortName.includes(search) || longName.includes(search) || |
|
||||
nodeNum.includes(search) || |
|
||||
nodeNumHex.includes(search.replace(/!/g, "")); |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "hopRange", |
|
||||
label: "Number of hops", |
|
||||
group: "General", |
|
||||
type: "range", |
|
||||
bounds: [0, 7], |
|
||||
predicate: (node, [min, max]: [number, number]) => { |
|
||||
const hops = node.hopsAway ?? 7; |
|
||||
return hops >= min && hops <= max; |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "lastHeard", |
|
||||
label: "Last heard", |
|
||||
group: "General", |
|
||||
type: "range", |
|
||||
bounds: [0, 864000], // 10 days
|
|
||||
predicate: (node, [min, max]: [number, number]) => { |
|
||||
const secondsAgo = Date.now() / 1000 - node.lastHeard; |
|
||||
return (secondsAgo >= min && secondsAgo <= max) || |
|
||||
(secondsAgo >= min && max == 864000); |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "favOnly", |
|
||||
label: "Show favourites only", |
|
||||
group: "General", |
|
||||
type: "boolean", |
|
||||
predicate: (node, favOnly: boolean) => !favOnly || node.isFavorite, |
|
||||
}, |
|
||||
{ |
|
||||
key: "viaMqtt", |
|
||||
label: "Hide MQTT-connected nodes", |
|
||||
group: "General", |
|
||||
type: "boolean", |
|
||||
predicate: (node, hide: boolean) => !hide || !node.viaMqtt, |
|
||||
}, |
|
||||
{ |
|
||||
key: "snr", |
|
||||
label: "SNR (db)", |
|
||||
group: "Metrics", |
|
||||
type: "range", |
|
||||
bounds: [-20, 10], |
|
||||
predicate: (node, [min, max]: [number, number]) => { |
|
||||
const snr = node.snr ?? -20; |
|
||||
return snr >= min && snr <= max; |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "channelUtilization", |
|
||||
label: "Channel Utilization (%)", |
|
||||
group: "Metrics", |
|
||||
type: "range", |
|
||||
bounds: [0, 100], |
|
||||
predicate: (node, [min, max]: [number, number]) => { |
|
||||
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0; |
|
||||
return channelUtilization >= min && channelUtilization <= max; |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "airUtilTx", |
|
||||
label: "Airtime Utilization (%)", |
|
||||
group: "Metrics", |
|
||||
type: "range", |
|
||||
bounds: [0, 100], |
|
||||
predicate: (node, [min, max]: [number, number]) => { |
|
||||
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0; |
|
||||
return airUtilTx >= min && airUtilTx <= max; |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "battery", |
|
||||
label: "Battery level (%)", |
|
||||
group: "Metrics", |
|
||||
type: "range", |
|
||||
bounds: [0, 101], |
|
||||
predicate: (node, [min, max]: [number, number]) => { |
|
||||
const batt = node.deviceMetrics?.batteryLevel ?? 101; |
|
||||
return batt >= min && batt <= max; |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "voltage", |
|
||||
label: "Battery voltage (V)", |
|
||||
group: "Metrics", |
|
||||
type: "range", |
|
||||
bounds: [0.1, 5.0], |
|
||||
predicate: (node, [min, max]: [number, number]) => { |
|
||||
const batt = node.deviceMetrics?.voltage ?? 5; |
|
||||
return batt >= min && batt <= max; |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "role", |
|
||||
label: "Role", |
|
||||
group: "Role", |
|
||||
type: "multi", |
|
||||
options: Object.keys(Protobuf.Config.Config_DeviceConfig_Role) |
|
||||
.filter((k) => isNaN(Number(k))) |
|
||||
.map((k) => { |
|
||||
const spaced = k.replace(/_/g, " "); |
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); |
|
||||
}), |
|
||||
predicate: (node, selected) => { |
|
||||
return selected.map((k) => { |
|
||||
const unSpaced = k.replace(/ /g, "_"); |
|
||||
return unSpaced.toUpperCase(); |
|
||||
}).includes( |
|
||||
Protobuf.Config.Config_DeviceConfig_Role[node.user?.role ?? 0], |
|
||||
); |
|
||||
}, |
|
||||
}, |
|
||||
{ |
|
||||
key: "hwModel", |
|
||||
label: "Hardware model", |
|
||||
group: "Hardware", |
|
||||
type: "multi", |
|
||||
options: Object.keys(Protobuf.Mesh.HardwareModel) |
|
||||
.filter((k) => isNaN(Number(k))) |
|
||||
.map((k) => { |
|
||||
return k.replace(/_/g, " "); |
|
||||
}), |
|
||||
predicate: (node, selected) => { |
|
||||
return selected.map((k) => { |
|
||||
const unSpaced = k.replace(/ /g, "_"); |
|
||||
return unSpaced.toUpperCase(); |
|
||||
}).includes(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]); |
|
||||
}, |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { |
|
||||
const defaultState = useMemo(() => { |
|
||||
return filterConfigs.reduce((acc, cfg) => { |
|
||||
switch (cfg.type) { |
|
||||
case "boolean": |
|
||||
acc[cfg.key] = false; |
|
||||
break; |
|
||||
case "range": |
|
||||
acc[cfg.key] = cfg.bounds; |
|
||||
break; |
|
||||
case "search": |
|
||||
acc[cfg.key] = ""; |
|
||||
break; |
|
||||
case "multi": |
|
||||
acc[cfg.key] = cfg.options; |
|
||||
break; |
|
||||
} |
|
||||
return acc; |
|
||||
}, {} as FilterValueMap); |
|
||||
}, []); |
|
||||
|
|
||||
const [filters, setFilters] = useState<FilterValueMap>( |
|
||||
defaultState, |
|
||||
); |
|
||||
|
|
||||
const groupedFilterConfigs = useMemo(() => { |
|
||||
return filterConfigs.reduce<Record<string, FilterConfig[]>>((acc, cfg) => { |
|
||||
const g = "group" in cfg ? cfg.group : "General"; |
|
||||
if (!acc[g]) acc[g] = []; |
|
||||
acc[g].push(cfg); |
|
||||
return acc; |
|
||||
}, {}); |
|
||||
}, [filterConfigs]); |
|
||||
|
|
||||
const resetFilters = useCallback(() => { |
|
||||
setFilters(defaultState); |
|
||||
}, [defaultState]); |
|
||||
|
|
||||
const onFilterChange = useCallback( |
|
||||
<K extends keyof FilterValueMap>(key: K, value: FilterValueMap[K]) => { |
|
||||
setFilters((f) => ({ ...f, [key]: value })); |
|
||||
}, |
|
||||
[], |
|
||||
); |
|
||||
|
|
||||
const filteredNodes = useMemo( |
|
||||
() => |
|
||||
nodes.filter((node) => |
|
||||
filterConfigs.every((cfg) => { |
|
||||
const val = filters[cfg.key]; |
|
||||
switch (cfg.type) { |
|
||||
case "boolean": |
|
||||
if (typeof val !== "boolean") return true; |
|
||||
return cfg.predicate(node, val); |
|
||||
|
|
||||
case "range": { |
|
||||
if ( |
|
||||
!Array.isArray(val) || |
|
||||
val.length !== 2 || |
|
||||
typeof val[0] !== "number" || |
|
||||
typeof val[1] !== "number" |
|
||||
) { |
|
||||
return true; |
|
||||
} |
|
||||
const tuple: [number, number] = [val[0], val[1]]; |
|
||||
return cfg.predicate(node, tuple); |
|
||||
} |
|
||||
case "multi": { |
|
||||
const safeArray = (() => { |
|
||||
if (!Array.isArray(val)) return []; |
|
||||
return val.filter((x): x is string => typeof x === "string"); |
|
||||
})(); |
|
||||
return cfg.predicate(node, safeArray); |
|
||||
} |
|
||||
case "search": |
|
||||
if (typeof val !== "string") return true; |
|
||||
return cfg.predicate(node, val); |
|
||||
} |
|
||||
}) |
|
||||
), |
|
||||
[nodes, filters], |
|
||||
); |
|
||||
|
|
||||
return { |
|
||||
filters, |
|
||||
defaultState, |
|
||||
onFilterChange, |
|
||||
resetFilters, |
|
||||
filteredNodes, |
|
||||
groupedFilterConfigs, |
|
||||
}; |
|
||||
} |
|
||||
@ -1,66 +0,0 @@ |
|||||
import { renderHook, act } from '@testing-library/react'; |
|
||||
import { describe, it, expect } from 'vitest'; |
|
||||
import { usePasswordVisibilityToggle } from './usePasswordVisibilityToggle.ts'; |
|
||||
|
|
||||
describe('usePasswordVisibilityToggle Hook', () => { |
|
||||
it('should initialize with visibility set to false by default', () => { |
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle()); |
|
||||
expect(result.current.isVisible).toBe(false); |
|
||||
expect(typeof result.current.toggleVisibility).toBe('function'); |
|
||||
}); |
|
||||
|
|
||||
it('should initialize with visibility set to true if initialVisible is true', () => { |
|
||||
const { result } = renderHook(() => |
|
||||
usePasswordVisibilityToggle({ initialVisible: true }) |
|
||||
); |
|
||||
expect(result.current.isVisible).toBe(true); |
|
||||
}); |
|
||||
|
|
||||
it('should toggle visibility from false to true when toggleVisibility is called', () => { |
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle()); |
|
||||
expect(result.current.isVisible).toBe(false); |
|
||||
act(() => { |
|
||||
result.current.toggleVisibility(); |
|
||||
}); |
|
||||
expect(result.current.isVisible).toBe(true); |
|
||||
}); |
|
||||
|
|
||||
it('should toggle visibility from true to false when toggleVisibility is called', () => { |
|
||||
const { result } = renderHook(() => |
|
||||
usePasswordVisibilityToggle({ initialVisible: true }) |
|
||||
); |
|
||||
expect(result.current.isVisible).toBe(true); |
|
||||
act(() => { |
|
||||
result.current.toggleVisibility(); |
|
||||
}); |
|
||||
expect(result.current.isVisible).toBe(false); |
|
||||
}); |
|
||||
|
|
||||
it('should toggle visibility correctly multiple times', () => { |
|
||||
const { result } = renderHook(() => usePasswordVisibilityToggle()); |
|
||||
expect(result.current.isVisible).toBe(false); |
|
||||
act(() => { |
|
||||
result.current.toggleVisibility(); |
|
||||
}); |
|
||||
expect(result.current.isVisible).toBe(true); |
|
||||
act(() => { |
|
||||
result.current.toggleVisibility(); |
|
||||
}); |
|
||||
expect(result.current.isVisible).toBe(false); |
|
||||
act(() => { |
|
||||
result.current.toggleVisibility(); |
|
||||
}); |
|
||||
expect(result.current.isVisible).toBe(true); |
|
||||
}); |
|
||||
|
|
||||
it('should return a stable toggleVisibility function reference (due to useCallback)', () => { |
|
||||
const { result, rerender } = renderHook(() => usePasswordVisibilityToggle()); |
|
||||
const initialToggleFunc = result.current.toggleVisibility; |
|
||||
rerender(); |
|
||||
expect(result.current.toggleVisibility).toBe(initialToggleFunc); |
|
||||
act(() => { |
|
||||
result.current.toggleVisibility(); |
|
||||
}); |
|
||||
expect(result.current.isVisible).toBe(true); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,20 +0,0 @@ |
|||||
import { useState, useCallback } from 'react'; |
|
||||
|
|
||||
interface UsePasswordVisibilityToggleProps { |
|
||||
initialVisible?: boolean; |
|
||||
} |
|
||||
/** |
|
||||
* Manages the state for toggling password visibility. |
|
||||
* |
|
||||
* @param {boolean} [options.initialVisible=false] |
|
||||
* @returns {{isVisible: boolean, toggleVisibility: () => void}} |
|
||||
*/ |
|
||||
export function usePasswordVisibilityToggle({ initialVisible = false }: UsePasswordVisibilityToggleProps = {}) { |
|
||||
const [isVisible, setIsVisible] = useState<boolean>(initialVisible); |
|
||||
|
|
||||
const toggleVisibility = useCallback(() => { |
|
||||
setIsVisible(prev => !prev); |
|
||||
}, []); |
|
||||
|
|
||||
return { isVisible, toggleVisibility }; |
|
||||
} |
|
||||
@ -1,212 +0,0 @@ |
|||||
import { create } from 'zustand'; |
|
||||
import { persist } from 'zustand/middleware'; |
|
||||
import { produce } from 'immer'; |
|
||||
import { Types } from '@meshtastic/core'; |
|
||||
import { storageWithMapSupport } from "../storage/indexDB.ts"; |
|
||||
import { ChannelId, ClearMessageParams, ConversationId, GetMessagesParams, Message, MessageId, MessageLogMap, NodeNum, SetMessageStateParams } from "@core/stores/messageStore/types.ts"; |
|
||||
|
|
||||
export enum MessageState { |
|
||||
Ack = "ack", |
|
||||
Waiting = "waiting", |
|
||||
Failed = "failed", |
|
||||
} |
|
||||
|
|
||||
export enum MessageType { |
|
||||
Direct = "direct", |
|
||||
Broadcast = "broadcast", |
|
||||
} |
|
||||
|
|
||||
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId { |
|
||||
return [node1, node2].sort((a, b) => a - b).join(':'); |
|
||||
} |
|
||||
|
|
||||
export interface MessageStore { |
|
||||
messages: { |
|
||||
direct: Map<ConversationId, MessageLogMap>; |
|
||||
broadcast: Map<ChannelId, MessageLogMap>; |
|
||||
}; |
|
||||
}; |
|
||||
export interface MessageStore { |
|
||||
messages: MessageStore['messages']; |
|
||||
draft: Map<Types.Destination, string>; |
|
||||
nodeNum: number; // This device's node number
|
|
||||
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
|
|
||||
chatType: MessageType; |
|
||||
|
|
||||
setNodeNum: (nodeNum: number) => void; |
|
||||
getMyNodeNum: () => number; |
|
||||
setActiveChat: (chat: number) => void; |
|
||||
setChatType: (type: MessageType) => void; |
|
||||
saveMessage: (message: Message) => void; |
|
||||
setMessageState: (params: SetMessageStateParams) => void; |
|
||||
getMessages: (params: GetMessagesParams) => Message[]; |
|
||||
getDraft: (key: Types.Destination) => string; |
|
||||
setDraft: (key: Types.Destination, message: string) => void; |
|
||||
deleteAllMessages: () => void; |
|
||||
clearMessageByMessageId: (params: ClearMessageParams) => void; |
|
||||
clearDraft: (key: Types.Destination) => void; |
|
||||
} |
|
||||
|
|
||||
const CURRENT_STORE_VERSION = 0; |
|
||||
|
|
||||
export const useMessageStore = create<MessageStore>()( |
|
||||
// persist(
|
|
||||
(set, get) => ({ |
|
||||
messages: { |
|
||||
direct: new Map<ConversationId, MessageLogMap>(), |
|
||||
broadcast: new Map<ChannelId, MessageLogMap>(), |
|
||||
}, |
|
||||
draft: new Map<number, string>(), |
|
||||
activeChat: 0, |
|
||||
chatType: MessageType.Broadcast, |
|
||||
nodeNum: 0, |
|
||||
setNodeNum: (nodeNum) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.nodeNum = nodeNum; |
|
||||
})); |
|
||||
}, |
|
||||
getMyNodeNum: () => get().nodeNum, |
|
||||
setActiveChat: (chat) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.activeChat = chat; |
|
||||
})); |
|
||||
}, |
|
||||
setChatType: (type) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.chatType = type; |
|
||||
})); |
|
||||
}, |
|
||||
saveMessage: (message: Message) => { |
|
||||
set( |
|
||||
produce((state: MessageStore) => { |
|
||||
if (message.type === MessageType.Direct) { |
|
||||
const conversationId = getConversationId(message.from, message.to); |
|
||||
if (!state.messages.direct.has(conversationId)) { |
|
||||
state.messages.direct.set(conversationId, new Map<MessageId, Message>()); |
|
||||
} |
|
||||
state.messages.direct.get(conversationId)!.set(message.messageId, message); |
|
||||
} else if (message.type === MessageType.Broadcast) { |
|
||||
const channelId = message.channel as ChannelId; |
|
||||
if (!state.messages.broadcast.has(channelId)) { |
|
||||
state.messages.broadcast.set(channelId, new Map<MessageId, Message>()); |
|
||||
} |
|
||||
state.messages.broadcast.get(channelId)!.set(message.messageId, message); |
|
||||
} |
|
||||
}) |
|
||||
); |
|
||||
}, |
|
||||
|
|
||||
setMessageState: (params: SetMessageStateParams) => { |
|
||||
set( |
|
||||
produce((state: MessageStore) => { |
|
||||
let messageLog: MessageLogMap | undefined; |
|
||||
let targetMessage: Message | undefined; |
|
||||
|
|
||||
if (params.type === MessageType.Direct) { |
|
||||
const conversationId = getConversationId(params.nodeA, params.nodeB); |
|
||||
messageLog = state.messages.direct.get(conversationId); |
|
||||
if (messageLog) { |
|
||||
targetMessage = messageLog.get(params.messageId); |
|
||||
} |
|
||||
} else { // Broadcast
|
|
||||
messageLog = state.messages.broadcast.get(params.channelId); |
|
||||
if (messageLog) { |
|
||||
targetMessage = messageLog.get(params.messageId); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (targetMessage) { |
|
||||
targetMessage.state = params.newState ?? MessageState.Ack; |
|
||||
} else { |
|
||||
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`); |
|
||||
} |
|
||||
}) |
|
||||
); |
|
||||
}, |
|
||||
getMessages: (params: GetMessagesParams): Message[] => { |
|
||||
const state = get(); |
|
||||
let messageMap: MessageLogMap | undefined; |
|
||||
|
|
||||
if (params.type === MessageType.Direct) { |
|
||||
const conversationId = getConversationId(params.nodeA, params.nodeB); |
|
||||
messageMap = state.messages.direct.get(conversationId); |
|
||||
|
|
||||
} else { |
|
||||
messageMap = state.messages.broadcast.get(params.channelId); |
|
||||
} |
|
||||
|
|
||||
if (messageMap === undefined) { |
|
||||
return []; |
|
||||
} |
|
||||
|
|
||||
const messagesArray = Array.from(messageMap.values()); |
|
||||
messagesArray.sort((a, b) => a.date - b.date); |
|
||||
return messagesArray; |
|
||||
}, |
|
||||
|
|
||||
clearMessageByMessageId: (params: ClearMessageParams) => { |
|
||||
set( |
|
||||
produce((state: MessageStore) => { |
|
||||
let messageLog: MessageLogMap | undefined; |
|
||||
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>; |
|
||||
let parentKey: ConversationId | ChannelId; |
|
||||
|
|
||||
if (params.type === MessageType.Direct) { |
|
||||
parentKey = getConversationId(params.nodeA, params.nodeB); |
|
||||
parentMap = state.messages.direct; |
|
||||
messageLog = parentMap.get(parentKey); |
|
||||
} else { |
|
||||
parentKey = params.channelId; |
|
||||
parentMap = state.messages.broadcast; |
|
||||
messageLog = parentMap.get(parentKey); |
|
||||
} |
|
||||
|
|
||||
if (messageLog) { |
|
||||
const deleted = messageLog.delete(params.messageId); |
|
||||
|
|
||||
if (deleted) { |
|
||||
console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`); |
|
||||
// Clean up empty MessageLogMap and its entry in the parent map
|
|
||||
if (messageLog.size === 0) { |
|
||||
parentMap.delete(parentKey); |
|
||||
console.log(`Cleaned up empty message entry for ${parentKey}`); |
|
||||
} |
|
||||
} else { |
|
||||
console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`); |
|
||||
} |
|
||||
} else { |
|
||||
console.warn(`Message entry ${parentKey} not found for message deletion.`); |
|
||||
} |
|
||||
}) |
|
||||
); |
|
||||
}, |
|
||||
getDraft: (key) => { |
|
||||
return get().draft.get(key) ?? ''; |
|
||||
}, |
|
||||
setDraft: (key, message) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.draft.set(key, message); |
|
||||
})); |
|
||||
}, |
|
||||
clearDraft: (key) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.draft.delete(key); |
|
||||
})); |
|
||||
}, |
|
||||
deleteAllMessages: () => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.messages.direct = new Map<ConversationId, MessageLogMap>(); |
|
||||
state.messages.broadcast = new Map<ChannelId, MessageLogMap>(); |
|
||||
})); |
|
||||
} |
|
||||
}), |
|
||||
// {
|
|
||||
// name: 'meshtastic-message-store',
|
|
||||
// storage: storageWithMapSupport,
|
|
||||
// version: CURRENT_STORE_VERSION,
|
|
||||
// partialize: (state) => ({
|
|
||||
// messages: state.messages,
|
|
||||
// nodeNum: state.nodeNum,
|
|
||||
// }),
|
|
||||
// })
|
|
||||
) |
|
||||
@ -1,486 +0,0 @@ |
|||||
import { describe, it, expect, beforeEach, vi } from 'vitest'; |
|
||||
import { |
|
||||
useMessageStore, |
|
||||
MessageType, |
|
||||
MessageState, |
|
||||
getConversationId, |
|
||||
} from './index.ts'; |
|
||||
import type { ConversationId, ChannelId, MessageLogMap, Message } from './types.ts'; |
|
||||
import { Types } from '@meshtastic/core'; |
|
||||
|
|
||||
vi.mock('../storage/indexDB.ts', () => { |
|
||||
let memoryStorage: Record<string, string> = {}; |
|
||||
return { |
|
||||
storageWithMapSupport: { |
|
||||
getItem: vi.fn(async (name: string): Promise<string | null> => { |
|
||||
return memoryStorage[name] ?? null; |
|
||||
}), |
|
||||
setItem: vi.fn(async (name: string, value: string): Promise<void> => { |
|
||||
memoryStorage[name] = value; |
|
||||
}), |
|
||||
removeItem: vi.fn(async (name: string): Promise<void> => { |
|
||||
delete memoryStorage[name]; |
|
||||
}), |
|
||||
}, |
|
||||
}; |
|
||||
}); |
|
||||
|
|
||||
const myNodeNum = 111; |
|
||||
const otherNodeNum1 = 222; |
|
||||
const otherNodeNum2 = 333; |
|
||||
const broadcastChannel: ChannelId = 0; |
|
||||
|
|
||||
const directMessageToOther1: Message = { |
|
||||
type: MessageType.Direct, |
|
||||
from: myNodeNum, |
|
||||
to: otherNodeNum1, |
|
||||
channel: 0, |
|
||||
date: Date.now(), |
|
||||
messageId: 101, |
|
||||
state: MessageState.Waiting, |
|
||||
message: 'Hello other 1 from me', |
|
||||
}; |
|
||||
|
|
||||
const directMessageFromOther1: Message = { |
|
||||
type: MessageType.Direct, |
|
||||
from: otherNodeNum1, |
|
||||
to: myNodeNum, |
|
||||
channel: 0, |
|
||||
date: Date.now() + 1000, |
|
||||
messageId: 102, |
|
||||
state: MessageState.Waiting, |
|
||||
message: 'Hello me from other 1', |
|
||||
}; |
|
||||
|
|
||||
const directMessageToOther2: Message = { |
|
||||
type: MessageType.Direct, |
|
||||
from: myNodeNum, |
|
||||
to: otherNodeNum2, |
|
||||
channel: 0, |
|
||||
date: Date.now() + 2000, |
|
||||
messageId: 103, |
|
||||
state: MessageState.Waiting, |
|
||||
message: 'Hello other 2 from me', |
|
||||
}; |
|
||||
|
|
||||
const broadcastMessage1: Message = { |
|
||||
type: MessageType.Broadcast, |
|
||||
from: otherNodeNum1, |
|
||||
to: 0xffffffff, |
|
||||
channel: broadcastChannel, |
|
||||
date: Date.now() + 3000, |
|
||||
messageId: 201, |
|
||||
state: MessageState.Waiting, |
|
||||
message: 'Broadcast message 1', |
|
||||
}; |
|
||||
|
|
||||
const broadcastMessage2: Message = { |
|
||||
type: MessageType.Broadcast, |
|
||||
from: myNodeNum, |
|
||||
to: 0xffffffff, |
|
||||
channel: broadcastChannel, |
|
||||
date: Date.now() + 4000, |
|
||||
messageId: 202, |
|
||||
state: MessageState.Waiting, |
|
||||
message: 'Broadcast message 2', |
|
||||
}; |
|
||||
|
|
||||
describe('useMessageStore', () => { |
|
||||
const initialState = useMessageStore.getState(); |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
useMessageStore.setState({ |
|
||||
...initialState, |
|
||||
messages: { |
|
||||
direct: new Map<ConversationId, MessageLogMap>(), |
|
||||
broadcast: new Map<ChannelId, MessageLogMap>(), |
|
||||
}, |
|
||||
draft: new Map<Types.Destination, string>(), |
|
||||
}, true); |
|
||||
|
|
||||
}); |
|
||||
|
|
||||
it('should have correct initial state', () => { |
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.direct).toBeInstanceOf(Map); |
|
||||
expect(state.messages.direct.size).toBe(0); |
|
||||
expect(state.messages.broadcast).toBeInstanceOf(Map); |
|
||||
expect(state.messages.broadcast.size).toBe(0); |
|
||||
expect(state.draft).toBeInstanceOf(Map); |
|
||||
expect(state.draft.size).toBe(0); |
|
||||
expect(state.nodeNum).toBe(0); |
|
||||
expect(state.activeChat).toBe(0); |
|
||||
expect(state.chatType).toBe(MessageType.Broadcast); |
|
||||
}); |
|
||||
|
|
||||
it('should set nodeNum', () => { |
|
||||
useMessageStore.getState().setNodeNum(myNodeNum); |
|
||||
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum); |
|
||||
}); |
|
||||
|
|
||||
it('should set activeChat and chatType', () => { |
|
||||
useMessageStore.getState().setActiveChat(otherNodeNum1); |
|
||||
useMessageStore.getState().setChatType(MessageType.Direct); |
|
||||
expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1); |
|
||||
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct); |
|
||||
}); |
|
||||
|
|
||||
describe('saveMessage', () => { |
|
||||
it('should save a direct message with correct Map structure', () => { |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
const state = useMessageStore.getState(); |
|
||||
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to); |
|
||||
|
|
||||
// Check if the conversation Map exists
|
|
||||
expect(state.messages.direct.has(conversationId)).toBe(true); |
|
||||
const conversationLog = state.messages.direct.get(conversationId); |
|
||||
// Check if the inner Map (MessageLogMap) exists and is a Map
|
|
||||
expect(conversationLog).toBeInstanceOf(Map); |
|
||||
// Check if the message exists within the inner Map
|
|
||||
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true); |
|
||||
// Check the message content
|
|
||||
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1); |
|
||||
}); |
|
||||
|
|
||||
it('should save a broadcast message with correct Map structure', () => { |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
const state = useMessageStore.getState(); |
|
||||
const channelId = broadcastMessage1.channel; |
|
||||
|
|
||||
expect(state.messages.broadcast.has(channelId)).toBe(true); |
|
||||
const channelLog = state.messages.broadcast.get(channelId); |
|
||||
expect(channelLog).toBeInstanceOf(Map); |
|
||||
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true); |
|
||||
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1); |
|
||||
}); |
|
||||
|
|
||||
it('should save multiple messages correctly', () => { |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
|
|
||||
const state = useMessageStore.getState(); |
|
||||
|
|
||||
const convId1 = getConversationId(myNodeNum, otherNodeNum1); |
|
||||
expect(state.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1); |
|
||||
|
|
||||
expect(state.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(directMessageFromOther1); |
|
||||
|
|
||||
const channelId = broadcastMessage1.channel; |
|
||||
expect(state.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
describe('getMessages', () => { |
|
||||
beforeEach(() => { |
|
||||
useMessageStore.getState().setNodeNum(myNodeNum); |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther2); |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage2); |
|
||||
}); |
|
||||
|
|
||||
it('should return broadcast messages for a channel, sorted by date', () => { |
|
||||
const messages = useMessageStore.getState().getMessages({ |
|
||||
type: MessageType.Broadcast, |
|
||||
channelId: broadcastChannel |
|
||||
}); |
|
||||
expect(messages).toHaveLength(2); |
|
||||
expect(messages[0]).toEqual(broadcastMessage1); |
|
||||
expect(messages[1]).toEqual(broadcastMessage2); |
|
||||
}); |
|
||||
|
|
||||
it('should return empty array for broadcast if channel has no messages', () => { |
|
||||
const messages = useMessageStore.getState().getMessages({ |
|
||||
type: MessageType.Broadcast, |
|
||||
channelId: Types.ChannelNumber.Channel1 |
|
||||
}); |
|
||||
expect(messages).toEqual([]); |
|
||||
}); |
|
||||
|
|
||||
it('should return combined direct messages for a specific chat pair, sorted by date', () => { |
|
||||
const messages = useMessageStore.getState().getMessages({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: myNodeNum, |
|
||||
nodeB: otherNodeNum1 |
|
||||
}); |
|
||||
expect(messages).toHaveLength(2); |
|
||||
expect(messages[0]).toEqual(directMessageToOther1); |
|
||||
expect(messages[1]).toEqual(directMessageFromOther1); |
|
||||
}); |
|
||||
|
|
||||
it('should return only relevant direct messages for a different chat pair', () => { |
|
||||
const messages = useMessageStore.getState().getMessages({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: myNodeNum, |
|
||||
nodeB: otherNodeNum2 |
|
||||
}); |
|
||||
expect(messages).toHaveLength(1); |
|
||||
expect(messages[0]).toEqual(directMessageToOther2); |
|
||||
}); |
|
||||
|
|
||||
it('should return empty array for direct chat if no messages exist', () => { |
|
||||
const messages = useMessageStore.getState().getMessages({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: myNodeNum, |
|
||||
nodeB: 999 |
|
||||
}); |
|
||||
expect(messages).toEqual([]); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
describe('setMessageState', () => { |
|
||||
beforeEach(() => { |
|
||||
useMessageStore.getState().setNodeNum(myNodeNum); |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
}); |
|
||||
|
|
||||
it('should update state for a direct message', () => { |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: directMessageToOther1.from, |
|
||||
nodeB: directMessageToOther1.to, |
|
||||
messageId: directMessageToOther1.messageId, |
|
||||
newState: MessageState.Ack, |
|
||||
}); |
|
||||
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to); |
|
||||
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageToOther1.messageId); |
|
||||
expect(message?.state).toBe(MessageState.Ack); |
|
||||
}); |
|
||||
|
|
||||
it('should update state for another direct message in the same conversation', () => { |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: directMessageFromOther1.from, |
|
||||
nodeB: directMessageFromOther1.to, |
|
||||
messageId: directMessageFromOther1.messageId, |
|
||||
newState: MessageState.Failed, |
|
||||
}); |
|
||||
const conversationId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to); |
|
||||
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageFromOther1.messageId); |
|
||||
expect(message?.state).toBe(MessageState.Failed); |
|
||||
}); |
|
||||
|
|
||||
it('should update state for a broadcast message', () => { |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Broadcast, |
|
||||
channelId: broadcastChannel, |
|
||||
messageId: broadcastMessage1.messageId, |
|
||||
newState: MessageState.Ack, |
|
||||
}); |
|
||||
const message = useMessageStore.getState().messages.broadcast.get(broadcastChannel)?.get(broadcastMessage1.messageId); |
|
||||
expect(message?.state).toBe(MessageState.Ack); |
|
||||
}); |
|
||||
|
|
||||
it('should warn if message is not found (direct)', () => { |
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: myNodeNum, |
|
||||
nodeB: otherNodeNum1, |
|
||||
messageId: 999, |
|
||||
newState: MessageState.Ack, |
|
||||
}); |
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); |
|
||||
warnSpy.mockRestore(); |
|
||||
}); |
|
||||
|
|
||||
it('should warn if message is not found (broadcast)', () => { |
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Broadcast, |
|
||||
channelId: broadcastChannel, |
|
||||
messageId: 999, |
|
||||
newState: MessageState.Ack, |
|
||||
}); |
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); |
|
||||
warnSpy.mockRestore(); |
|
||||
}); |
|
||||
|
|
||||
it('should warn if conversation/channel is not found', () => { |
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: myNodeNum, |
|
||||
nodeB: 998, |
|
||||
messageId: 101, |
|
||||
newState: MessageState.Ack, |
|
||||
}); |
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update')); |
|
||||
warnSpy.mockRestore(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
describe('clearMessageByMessageId', () => { |
|
||||
const extraDirectMessageId = 1011; |
|
||||
beforeEach(() => { |
|
||||
useMessageStore.getState().setNodeNum(myNodeNum); |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: extraDirectMessageId, date: Date.now() + 50 }); |
|
||||
}); |
|
||||
|
|
||||
it('should delete a specific direct message', () => { |
|
||||
const messageIdToDelete = directMessageToOther1.messageId; |
|
||||
const nodeA = directMessageToOther1.from; |
|
||||
const nodeB = directMessageToOther1.to; |
|
||||
const conversationId = getConversationId(nodeA, nodeB); |
|
||||
|
|
||||
useMessageStore.getState().clearMessageByMessageId({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: nodeA, |
|
||||
nodeB: nodeB, |
|
||||
messageId: messageIdToDelete |
|
||||
}); |
|
||||
|
|
||||
const state = useMessageStore.getState(); |
|
||||
const conversationLog = state.messages.direct.get(conversationId); |
|
||||
expect(conversationLog?.has(messageIdToDelete)).toBe(false); |
|
||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true); |
|
||||
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true); |
|
||||
expect(state.messages.direct.has(conversationId)).toBe(true); |
|
||||
|
|
||||
}); |
|
||||
|
|
||||
it('should delete another specific direct message', () => { |
|
||||
const messageIdToDelete = directMessageFromOther1.messageId; |
|
||||
const nodeA = directMessageFromOther1.from; |
|
||||
const nodeB = directMessageFromOther1.to; |
|
||||
const conversationId = getConversationId(nodeA, nodeB); |
|
||||
|
|
||||
useMessageStore.getState().clearMessageByMessageId({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: nodeA, |
|
||||
nodeB: nodeB, |
|
||||
messageId: messageIdToDelete |
|
||||
}); |
|
||||
|
|
||||
const state = useMessageStore.getState(); |
|
||||
const conversationLog = state.messages.direct.get(conversationId); |
|
||||
expect(conversationLog?.has(messageIdToDelete)).toBe(false); |
|
||||
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true); |
|
||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true); |
|
||||
}); |
|
||||
|
|
||||
|
|
||||
it('should delete a specific broadcast message', () => { |
|
||||
const messageIdToDelete = broadcastMessage1.messageId; |
|
||||
const channelId = broadcastMessage1.channel; |
|
||||
|
|
||||
useMessageStore.getState().clearMessageByMessageId({ |
|
||||
type: MessageType.Broadcast, |
|
||||
channelId: channelId, |
|
||||
messageId: messageIdToDelete |
|
||||
}); |
|
||||
|
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined(); |
|
||||
}); |
|
||||
|
|
||||
it('should clean up empty conversation/channel Maps', () => { |
|
||||
const directConvId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to); |
|
||||
const broadcastChanId = broadcastMessage1.channel; |
|
||||
|
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: directMessageToOther1.messageId }); |
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, messageId: directMessageFromOther1.messageId }); |
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: extraDirectMessageId }); |
|
||||
|
|
||||
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(false); |
|
||||
|
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId }); |
|
||||
|
|
||||
expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId)).toBe(false); |
|
||||
}); |
|
||||
|
|
||||
it('should not error when trying to delete non-existent message', () => { |
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); |
|
||||
const conversationId = getConversationId(myNodeNum, otherNodeNum1); |
|
||||
|
|
||||
expect(() => { |
|
||||
useMessageStore.getState().clearMessageByMessageId({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: myNodeNum, |
|
||||
nodeB: otherNodeNum1, |
|
||||
messageId: 9999 |
|
||||
}); |
|
||||
}).not.toThrow(); |
|
||||
|
|
||||
const state = useMessageStore.getState(); |
|
||||
const conversationLog = state.messages.direct.get(conversationId); |
|
||||
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
|
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not found in direct chat')); |
|
||||
|
|
||||
warnSpy.mockRestore(); |
|
||||
}); |
|
||||
|
|
||||
it('should not error when trying to delete from non-existent conversation/channel', () => { |
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); |
|
||||
expect(() => { |
|
||||
useMessageStore.getState().clearMessageByMessageId({ |
|
||||
type: MessageType.Direct, |
|
||||
nodeA: myNodeNum, |
|
||||
nodeB: 9998, |
|
||||
messageId: 101 |
|
||||
}); |
|
||||
}).not.toThrow(); |
|
||||
|
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry")); |
|
||||
|
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1); |
|
||||
|
|
||||
warnSpy.mockRestore(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
describe('Drafts', () => { |
|
||||
const draftKeyDirect = otherNodeNum1; |
|
||||
const draftKeyBroadcast = broadcastChannel; |
|
||||
const draftMessage = 'This is a draft'; |
|
||||
|
|
||||
it('should set and get a draft for direct chat', () => { |
|
||||
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage); |
|
||||
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(draftMessage); |
|
||||
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(draftMessage); |
|
||||
}); |
|
||||
|
|
||||
it('should set and get a draft for broadcast chat', () => { |
|
||||
useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage); |
|
||||
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(draftMessage); |
|
||||
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(draftMessage); |
|
||||
}); |
|
||||
|
|
||||
it('should return empty string for non-existent draft', () => { |
|
||||
expect(useMessageStore.getState().getDraft(999)).toBe(''); |
|
||||
}); |
|
||||
|
|
||||
it('should clear a draft', () => { |
|
||||
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage); |
|
||||
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true); |
|
||||
useMessageStore.getState().clearDraft(draftKeyDirect); |
|
||||
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false); |
|
||||
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(''); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
describe('deleteAllMessages', () => { |
|
||||
it('should clear all direct and broadcast messages, leaving empty Maps', () => { |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
|
|
||||
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(0); |
|
||||
expect(useMessageStore.getState().messages.broadcast.size).toBeGreaterThan(0); |
|
||||
|
|
||||
useMessageStore.getState().deleteAllMessages(); |
|
||||
|
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.direct).toBeInstanceOf(Map); |
|
||||
expect(state.messages.direct.size).toBe(0); |
|
||||
expect(state.messages.broadcast).toBeInstanceOf(Map); |
|
||||
expect(state.messages.broadcast.size).toBe(0); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,70 +0,0 @@ |
|||||
import { Types } from "@meshtastic/core"; |
|
||||
import { MessageState, MessageType } from "@core/stores/messageStore/index.ts"; |
|
||||
|
|
||||
type NodeNum = number; |
|
||||
type MessageId = number; |
|
||||
type ChannelId = Types.ChannelNumber; |
|
||||
type ConversationId = string; |
|
||||
type MessageLogMap = Map<MessageId, Message>; |
|
||||
|
|
||||
interface MessageBase { |
|
||||
channel: Types.ChannelNumber; |
|
||||
to: number; |
|
||||
from: number; |
|
||||
date: number; |
|
||||
messageId: number; |
|
||||
state: MessageState; |
|
||||
message: string; |
|
||||
} |
|
||||
|
|
||||
interface GenericMessage<T extends MessageType> extends MessageBase { |
|
||||
type: T; |
|
||||
} |
|
||||
|
|
||||
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>; |
|
||||
|
|
||||
|
|
||||
type GetMessagesParams = |
|
||||
| { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum } |
|
||||
| { type: MessageType.Broadcast; channelId: ChannelId }; |
|
||||
|
|
||||
|
|
||||
type SetMessageStateParams = |
|
||||
| { |
|
||||
type: MessageType.Direct; |
|
||||
nodeA: NodeNum; |
|
||||
nodeB: NodeNum; |
|
||||
messageId: MessageId; // ID of the message within that chat
|
|
||||
newState?: MessageState; // Optional new state, defaults to Ack
|
|
||||
} |
|
||||
| { |
|
||||
type: MessageType.Broadcast; |
|
||||
channelId: ChannelId; |
|
||||
messageId: MessageId; |
|
||||
newState?: MessageState; // Optional new state, defaults to Ack
|
|
||||
}; |
|
||||
|
|
||||
type ClearMessageParams = |
|
||||
| { |
|
||||
type: MessageType.Direct; |
|
||||
nodeA: NodeNum; |
|
||||
nodeB: NodeNum; |
|
||||
messageId: MessageId; |
|
||||
} |
|
||||
| { |
|
||||
type: MessageType.Broadcast; |
|
||||
channelId: ChannelId; |
|
||||
messageId: MessageId; |
|
||||
}; |
|
||||
|
|
||||
export type { |
|
||||
Message, |
|
||||
ConversationId, |
|
||||
NodeNum, |
|
||||
MessageLogMap, |
|
||||
ChannelId, |
|
||||
MessageId, |
|
||||
GetMessagesParams, |
|
||||
SetMessageStateParams, |
|
||||
ClearMessageParams, |
|
||||
} |
|
||||
@ -1,37 +0,0 @@ |
|||||
import React, { createContext, useState, useContext, useMemo } from 'react'; |
|
||||
|
|
||||
interface SidebarContextProps { |
|
||||
isCollapsed: boolean; |
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>; |
|
||||
toggleSidebar: () => void; |
|
||||
} |
|
||||
|
|
||||
const SidebarContext = createContext<SidebarContextProps | undefined>(undefined); |
|
||||
|
|
||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { |
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false); |
|
||||
|
|
||||
const toggleSidebar = useMemo(() => () => { |
|
||||
setIsCollapsed(prev => !prev); |
|
||||
}, []); |
|
||||
|
|
||||
const value = useMemo(() => ({ |
|
||||
isCollapsed, |
|
||||
setIsCollapsed, |
|
||||
toggleSidebar, |
|
||||
}), [isCollapsed, toggleSidebar]); |
|
||||
|
|
||||
return ( |
|
||||
<SidebarContext.Provider value={value} > |
|
||||
{children} |
|
||||
</SidebarContext.Provider> |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
export const useSidebar = (): SidebarContextProps => { |
|
||||
const context = useContext(SidebarContext); |
|
||||
if (context === undefined) { |
|
||||
throw new Error('useSidebar must be used within a SidebarProvider'); |
|
||||
} |
|
||||
return context; |
|
||||
}; |
|
||||
@ -1,81 +0,0 @@ |
|||||
import { PersistStorage, StateStorage, } from "zustand/middleware"; // Added StorageValue for clarity, though not strictly needed in the final signature here
|
|
||||
import { get, set, del } from "idb-keyval"; |
|
||||
import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts"; |
|
||||
|
|
||||
type PersistedMessageState = { |
|
||||
messages: { |
|
||||
direct: Map<string, MessageLogMap>; |
|
||||
broadcast: Map<ChannelId, MessageLogMap>; |
|
||||
}; |
|
||||
nodeNum: number; |
|
||||
}; |
|
||||
|
|
||||
export const zustandIndexDBStorage: StateStorage = { |
|
||||
getItem: async (name: string): Promise<string | null> => { |
|
||||
return (await get(name)) || null; |
|
||||
}, |
|
||||
setItem: async (name: string, value: string): Promise<void> => { |
|
||||
await set(name, value); |
|
||||
}, |
|
||||
removeItem: async (name: string): Promise<void> => { |
|
||||
await del(name); |
|
||||
}, |
|
||||
}; |
|
||||
|
|
||||
|
|
||||
type SerializedMap<K = unknown, V = unknown> = { |
|
||||
__dataType: 'Map'; |
|
||||
value: Array<[K, V]>; |
|
||||
}; |
|
||||
type JsonReplacer = (this: any, key: string, value: unknown) => unknown; |
|
||||
const replacer: JsonReplacer = (_, value) => { |
|
||||
if (value instanceof Map) { |
|
||||
const map = value as Map<unknown, unknown>; |
|
||||
const serialized: SerializedMap = { |
|
||||
__dataType: 'Map', |
|
||||
value: Array.from(map.entries()), |
|
||||
}; |
|
||||
return serialized; |
|
||||
} |
|
||||
return value; |
|
||||
}; |
|
||||
type JsonReviver = (this: any, key: string, value: unknown) => unknown; |
|
||||
function isSerializedMap(value: unknown): value is SerializedMap { |
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) { |
|
||||
return false; |
|
||||
} |
|
||||
const potentialMap = value as Partial<SerializedMap>; |
|
||||
return potentialMap.__dataType === 'Map' && Array.isArray(potentialMap.value); |
|
||||
} |
|
||||
const reviver: JsonReviver = (_, value) => { |
|
||||
if (isSerializedMap(value)) { |
|
||||
return new Map(value.value); |
|
||||
} |
|
||||
return value; |
|
||||
}; |
|
||||
|
|
||||
|
|
||||
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = { |
|
||||
getItem: async (name): Promise<PersistedMessageState | null> => { |
|
||||
const str = await zustandIndexDBStorage.getItem(name); |
|
||||
if (!str) { return null; } |
|
||||
try { |
|
||||
const parsed = JSON.parse(str, reviver) as PersistedMessageState; |
|
||||
return parsed; |
|
||||
} catch (error) { |
|
||||
console.error(`Error parsing persisted state (${name}):`, error); |
|
||||
return null; |
|
||||
} |
|
||||
}, |
|
||||
setItem: async (name, newValue: PersistedMessageState): Promise<void> => { |
|
||||
try { |
|
||||
const str = JSON.stringify(newValue, replacer); |
|
||||
await zustandIndexDBStorage.setItem(name, str); |
|
||||
} catch (error) { |
|
||||
console.error(`Error stringifying or setting persisted state (${name}):`, error); |
|
||||
} |
|
||||
}, |
|
||||
removeItem: async (name): Promise<void> => { |
|
||||
await zustandIndexDBStorage.removeItem(name); |
|
||||
}, |
|
||||
}; |
|
||||
@ -1,265 +0,0 @@ |
|||||
import { |
|
||||
Popover, |
|
||||
PopoverContent, |
|
||||
PopoverTrigger, |
|
||||
} from "@components/UI/Popover.tsx"; |
|
||||
import { FunnelIcon } from "lucide-react"; |
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx"; |
|
||||
import { Slider } from "@components/UI/Slider.tsx"; |
|
||||
import { ScrollArea } from "@components/UI/ScrollArea.tsx"; |
|
||||
import { |
|
||||
Accordion, |
|
||||
AccordionContent, |
|
||||
AccordionHeader, |
|
||||
AccordionItem, |
|
||||
AccordionTrigger, |
|
||||
} from "@components/UI/Accordion.tsx"; |
|
||||
import type { |
|
||||
FilterConfig, |
|
||||
FilterValueMap, |
|
||||
} from "@core/hooks/useNodeFilters.ts"; |
|
||||
import { cn } from "@core/utils/cn.ts"; |
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
|
||||
|
|
||||
interface FilterControlProps { |
|
||||
groupedFilterConfigs: Record<string, FilterConfig[]>; |
|
||||
values: FilterValueMap; |
|
||||
onChange: <K extends keyof FilterValueMap>( |
|
||||
key: K, |
|
||||
value: FilterValueMap[K], |
|
||||
) => void; |
|
||||
resetFilters: () => void; |
|
||||
isDirty: boolean; |
|
||||
children?: React.ReactNode; |
|
||||
} |
|
||||
|
|
||||
export function FilterControl( |
|
||||
{ groupedFilterConfigs, values, onChange, resetFilters, isDirty, children }: |
|
||||
FilterControlProps, |
|
||||
) { |
|
||||
return ( |
|
||||
<Popover> |
|
||||
<PopoverTrigger asChild> |
|
||||
<button |
|
||||
type="button" |
|
||||
className={cn( |
|
||||
"fixed bottom-17 right-2 px-1 py-1 rounded shadow-md", |
|
||||
isDirty |
|
||||
? " text-slate-100 bg-green-600 hover:bg-green-700 hover:text-slate-200 active:bg-green-800" |
|
||||
: "text-slate-600 bg-slate-100 hover:bg-slate-200 hover:text-slate-700 active:bg-slate-300", |
|
||||
)} |
|
||||
aria-label="Filter" |
|
||||
> |
|
||||
<FunnelIcon /> |
|
||||
</button> |
|
||||
</PopoverTrigger> |
|
||||
<PopoverContent |
|
||||
side="bottom" |
|
||||
align="end" |
|
||||
sideOffset={12} |
|
||||
className="dark:bg-slate-100 dark:border-slate-300" |
|
||||
> |
|
||||
<div className="space-y-4"> |
|
||||
<Accordion |
|
||||
className="AccordionRoot" |
|
||||
type="single" |
|
||||
defaultValue={Object.entries(groupedFilterConfigs)[0][0]} |
|
||||
collapsible |
|
||||
> |
|
||||
{Object.entries(groupedFilterConfigs).map(( |
|
||||
[groupName, groupConfigs], |
|
||||
) => ( |
|
||||
<AccordionItem key={groupName} value={groupName}> |
|
||||
<AccordionHeader> |
|
||||
<AccordionTrigger className="w-full text-left font-bold text-sm px-1 py-2"> |
|
||||
{groupName} |
|
||||
</AccordionTrigger> |
|
||||
</AccordionHeader> |
|
||||
<AccordionContent className="px-1 pb-4 pt-2 space-y-3"> |
|
||||
{groupConfigs.map((cfg) => { |
|
||||
const val = values[cfg.key]; |
|
||||
switch (cfg.type) { |
|
||||
case "boolean": |
|
||||
if (typeof val !== "boolean") return null; |
|
||||
return ( |
|
||||
<Checkbox |
|
||||
key={cfg.key} |
|
||||
checked={val} |
|
||||
onChange={(v) => onChange(cfg.key, v)} |
|
||||
className="pb-1" |
|
||||
labelClassName="dark:text-slate-900" |
|
||||
> |
|
||||
{cfg.label} |
|
||||
</Checkbox> |
|
||||
); |
|
||||
case "range": { |
|
||||
if ( |
|
||||
!Array.isArray(val) || |
|
||||
val.length !== 2 || |
|
||||
typeof val[0] !== "number" || |
|
||||
typeof val[1] !== "number" |
|
||||
) { |
|
||||
return null; |
|
||||
} |
|
||||
const [min, max] = val; |
|
||||
const [lo, hi] = cfg.bounds; |
|
||||
|
|
||||
let formattedMin = null; |
|
||||
let formattedMax = null; |
|
||||
|
|
||||
// Some filters require special formatting for min/max values
|
|
||||
if (cfg.key == "battery" && min == hi) { |
|
||||
formattedMin = "Charging"; |
|
||||
} |
|
||||
if (cfg.key == "battery" && max == hi) { |
|
||||
formattedMax = "Charging"; |
|
||||
} |
|
||||
if (cfg.key == "hopRange" && min == lo) { |
|
||||
formattedMin = "Direct"; |
|
||||
} |
|
||||
if (cfg.key == "lastHeard") { |
|
||||
formattedMin = ( |
|
||||
<> |
|
||||
<br /> |
|
||||
{min === lo ? "now" : ( |
|
||||
<TimeAgo |
|
||||
timestamp={Date.now() - min * 1000} |
|
||||
/> |
|
||||
)} |
|
||||
</> |
|
||||
); |
|
||||
|
|
||||
formattedMax = ( |
|
||||
<> |
|
||||
{max === hi ? ">" : ""} |
|
||||
<TimeAgo timestamp={Date.now() - max * 1000} /> |
|
||||
</> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return ( |
|
||||
<div key={cfg.key} className="space-y-2"> |
|
||||
<label className="block text-sm font-medium"> |
|
||||
{cfg.label}:{" "} |
|
||||
{min === max ? formattedMin ?? min : ( |
|
||||
<> |
|
||||
{formattedMin ?? min} – {formattedMax ?? max} |
|
||||
</> |
|
||||
)} |
|
||||
</label> |
|
||||
<Slider |
|
||||
value={[min, max]} |
|
||||
min={lo} |
|
||||
max={hi} |
|
||||
step={Number.isInteger(lo) ? 1 : 0.1} |
|
||||
onValueChange={(newRange) => { |
|
||||
const [newMin, newMax] = newRange; |
|
||||
onChange(cfg.key, [newMin, newMax]); |
|
||||
}} |
|
||||
className="w-full pb-3" |
|
||||
trackClassName="h-1 bg-slate-200 dark:bg-slate-700" |
|
||||
rangeClassName="bg-blue-500" |
|
||||
thumbClassName="w-3 h-3 bg-white border border-slate-400 dark:border-slate-600" |
|
||||
aria-label={`Slider - ${cfg.label}`} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
case "multi": { |
|
||||
const safeArray = (() => { |
|
||||
if (!Array.isArray(val)) return []; |
|
||||
return val.filter((x): x is string => |
|
||||
typeof x === "string" |
|
||||
); |
|
||||
})(); |
|
||||
|
|
||||
const allSelected = cfg.options.length > 0 && |
|
||||
cfg.options.every((opt) => safeArray.includes(opt)); |
|
||||
|
|
||||
return ( |
|
||||
<ScrollArea className="h-64 border rounded-md"> |
|
||||
<div |
|
||||
key={cfg.key} |
|
||||
className="space-y-2 px-2 py-3" |
|
||||
> |
|
||||
<button |
|
||||
type="button" |
|
||||
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-800" |
|
||||
onClick={() => |
|
||||
onChange( |
|
||||
cfg.key, |
|
||||
allSelected ? [] : [...cfg.options], |
|
||||
)} |
|
||||
> |
|
||||
{allSelected ? "Uncheck All" : "Check All"} |
|
||||
</button> |
|
||||
{cfg.options.map((opt) => ( |
|
||||
<Checkbox |
|
||||
key={opt.replace(/ /g, "_")} |
|
||||
checked={safeArray.includes(opt)} |
|
||||
onChange={(checked) => |
|
||||
onChange( |
|
||||
cfg.key, |
|
||||
checked |
|
||||
? [...safeArray, opt] |
|
||||
: safeArray.filter((s) => s !== opt), |
|
||||
)} |
|
||||
> |
|
||||
{opt} |
|
||||
</Checkbox> |
|
||||
))} |
|
||||
</div> |
|
||||
</ScrollArea> |
|
||||
); |
|
||||
} |
|
||||
case "search": |
|
||||
if (typeof val !== "string") return null; |
|
||||
return ( |
|
||||
<div |
|
||||
key={`${cfg.key}_div`} |
|
||||
className="flex flex-col space-y-1 pb-2" |
|
||||
> |
|
||||
<label |
|
||||
htmlFor={cfg.key} |
|
||||
className="font-medium text-sm" |
|
||||
> |
|
||||
{cfg.label} |
|
||||
</label> |
|
||||
<input |
|
||||
id={cfg.key} |
|
||||
type="text" |
|
||||
value={val} |
|
||||
onChange={(e) => |
|
||||
onChange(cfg.key, e.target.value)} |
|
||||
placeholder="Search phrase" |
|
||||
className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600" |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
|
|
||||
default: |
|
||||
return null; |
|
||||
} |
|
||||
})} |
|
||||
</AccordionContent> |
|
||||
</AccordionItem> |
|
||||
))} |
|
||||
</Accordion> |
|
||||
<button |
|
||||
type="button" |
|
||||
onClick={resetFilters} |
|
||||
className="w-full py-1 shadow-sm hover:shadow-md bg-slate-600 text-white rounded text-sm hover:text-slate-100 hover:bg-slate-700 active:bg-slate-800" |
|
||||
> |
|
||||
Reset Filters |
|
||||
</button> |
|
||||
|
|
||||
{children && ( |
|
||||
<div className="mt-4 border-t pt-4"> |
|
||||
{children} |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
</PopoverContent> |
|
||||
</Popover> |
|
||||
); |
|
||||
} |
|
||||
@ -1,74 +0,0 @@ |
|||||
import { describe, it, vi, expect } from "vitest"; |
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; |
|
||||
import { MessagesPage } from "./Messages.tsx"; |
|
||||
import { useDevice } from "../core/stores/deviceStore"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
vi.mock("../core/stores/deviceStore", () => ({ |
|
||||
useDevice: vi.fn() |
|
||||
})); |
|
||||
|
|
||||
const mockUseDevice = { |
|
||||
channels: new Map([ |
|
||||
[0, { |
|
||||
index: 0, |
|
||||
settings: { name: "Primary" }, |
|
||||
role: Protobuf.Channel.Channel_Role.PRIMARY |
|
||||
}] |
|
||||
]), |
|
||||
nodes: new Map([ |
|
||||
[0, { |
|
||||
num: 0, |
|
||||
user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" } |
|
||||
}], |
|
||||
[1111, { |
|
||||
num: 1111, |
|
||||
user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" } |
|
||||
}], |
|
||||
[2222, { |
|
||||
num: 2222, |
|
||||
user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" } |
|
||||
}], |
|
||||
[3333, { |
|
||||
num: 3333, |
|
||||
user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" } |
|
||||
}] |
|
||||
]), |
|
||||
hardware: { myNodeNum: 1 }, |
|
||||
messages: { broadcast: new Map(), direct: new Map() }, |
|
||||
metadata: new Map(), |
|
||||
unreadCounts: new Map([[1111, 3], [2222, 10]]), |
|
||||
resetUnread: vi.fn(), |
|
||||
hasNodeError: vi.fn() |
|
||||
}; |
|
||||
|
|
||||
|
|
||||
describe.skip("Messages Page", () => { |
|
||||
beforeEach(() => { |
|
||||
vi.mocked(useDevice).mockReturnValue(mockUseDevice); |
|
||||
}); |
|
||||
|
|
||||
it("sorts unreads to the top", () => { |
|
||||
render(<MessagesPage />); |
|
||||
const buttonOrder = screen.getAllByRole("button").filter(b => b.textContent.includes("Test Node")); |
|
||||
expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); |
|
||||
expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); |
|
||||
expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); |
|
||||
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); |
|
||||
}); |
|
||||
|
|
||||
it("updates unread when active chat changes", () => { |
|
||||
render(<MessagesPage />); |
|
||||
const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 13"))[0]; |
|
||||
fireEvent.click(nodeButton); |
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); |
|
||||
}); |
|
||||
|
|
||||
it("does not update the incorrect node", async () => { |
|
||||
render(<MessagesPage />); |
|
||||
const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 1"))[0]; |
|
||||
fireEvent.click(nodeButton); |
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); |
|
||||
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,221 +1,163 @@ |
|||||
|
import { useAppStore } from "../core/stores/appStore.ts"; |
||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx"; |
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx"; |
||||
import { PageLayout } from "@components/PageLayout.tsx"; |
import { PageLayout } from "@components/PageLayout.tsx"; |
||||
import { Sidebar } from "@components/Sidebar.tsx"; |
import { Sidebar } from "@components/Sidebar.tsx"; |
||||
import { Avatar } from "@components/UI/Avatar.tsx"; |
import { Avatar } from "@components/UI/Avatar.tsx"; |
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; |
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; |
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; |
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; |
||||
import { useToast } from "@core/hooks/useToast.ts"; |
import { useToast } from "@core/hooks/useToast.ts"; |
||||
import { useDevice } from "@core/stores/deviceStore.ts"; |
import { useDevice } from "@core/stores/deviceStore.ts"; |
||||
import { Protobuf, Types } from "@meshtastic/core"; |
import { Protobuf, Types } from "@meshtastic/core"; |
||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
||||
import { getChannelName } from "@pages/Channels.tsx"; |
import { getChannelName } from "@pages/Channels.tsx"; |
||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; |
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; |
||||
import { useCallback, useDeferredValue, useMemo, 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"; |
import { cn } from "@core/utils/cn.ts"; |
||||
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore/index.ts"; |
|
||||
import { useSidebar } from "@core/stores/sidebarStore.tsx"; |
|
||||
import { Input } from "@components/UI/Input.tsx"; |
|
||||
|
|
||||
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; |
|
||||
|
|
||||
export const MessagesPage = () => { |
export const MessagesPage = () => { |
||||
const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread, connection } = useDevice(); |
const { channels, nodes, hardware, messages, hasNodeError } = useDevice(); |
||||
const { getMyNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType, setMessageState, } = useMessageStore() |
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore(); |
||||
const { toast } = useToast(); |
|
||||
const { isCollapsed } = useSidebar() |
|
||||
const [searchTerm, setSearchTerm] = useState<string>(""); |
const [searchTerm, setSearchTerm] = useState<string>(""); |
||||
const deferredSearch = useDeferredValue(searchTerm); |
const filteredNodes = Array.from(nodes.values()).filter((node) => { |
||||
|
if (node.num === hardware.myNodeNum) return false; |
||||
const filteredNodes = (): NodeInfoWithUnread[] => { |
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; |
||||
const lowerCaseSearchTerm = deferredSearch.toLowerCase(); |
return nodeName.toLowerCase().includes(searchTerm.toLowerCase()); |
||||
|
}); |
||||
return getNodes(node => { |
|
||||
const longName = node.user?.longName?.toLowerCase() ?? ''; |
|
||||
const shortName = node.user?.shortName?.toLowerCase() ?? ''; |
|
||||
return longName.includes(lowerCaseSearchTerm) || shortName.includes(lowerCaseSearchTerm) |
|
||||
}) |
|
||||
.map((node) => ({ |
|
||||
...node, |
|
||||
unreadCount: unreadCounts.get(node.num) ?? 0, |
|
||||
})) |
|
||||
.sort((a, b) => b.unreadCount - a.unreadCount); |
|
||||
} |
|
||||
|
|
||||
const allChannels = Array.from(channels.values()); |
const allChannels = Array.from(channels.values()); |
||||
const filteredChannels = allChannels.filter( |
const filteredChannels = allChannels.filter( |
||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, |
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, |
||||
); |
); |
||||
const currentChannel = channels.get(activeChat); |
const currentChannel = channels.get(activeChat); |
||||
const otherNode = getNode(activeChat); |
const { toast } = useToast(); |
||||
|
const node = nodes.get(activeChat); |
||||
const isDirect = chatType === MessageType.Direct; |
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown"; |
||||
const isBroadcast = chatType === MessageType.Broadcast; |
|
||||
|
|
||||
const sendText = useCallback(async (message: string) => { |
|
||||
const isDirect = chatType === MessageType.Direct; |
|
||||
const toValue = isDirect ? activeChat : MessageType.Broadcast; |
|
||||
|
|
||||
const channelValue = isDirect ? Types.ChannelNumber.Primary : activeChat ?? 0; |
|
||||
|
|
||||
console.log(`Sending message: "${message}" to: ${toValue}, channel: ${channelValue}, type: ${chatType}`); |
|
||||
|
|
||||
let messageId: number | undefined; |
|
||||
|
|
||||
try { |
|
||||
messageId = await connection?.sendText(message, toValue, true, channelValue); |
|
||||
if (messageId !== undefined) { |
|
||||
if (chatType === MessageType.Broadcast) { |
|
||||
setMessageState({ type: chatType, channelId: channelValue, messageId, newState: MessageState.Ack }); |
|
||||
} else { |
|
||||
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId, newState: MessageState.Ack }); |
|
||||
} |
|
||||
} else { |
|
||||
console.warn("sendText completed but messageId is undefined"); |
|
||||
} |
|
||||
// deno-lint-ignore no-explicit-any
|
|
||||
} catch (e: any) { |
|
||||
console.error("Failed to send message:", e); |
|
||||
// Note: messageId might be undefined here if the error occurred before it was assigned
|
|
||||
if (chatType === MessageType.Broadcast) { |
|
||||
const failedId = messageId ?? `failed-${Date.now()}`; |
|
||||
setMessageState({ type: chatType, channelId: channelValue, messageId: failedId, newState: MessageState.Failed }); |
|
||||
} else { // MessageType.Direct
|
|
||||
const failedId = messageId ?? `failed-${Date.now()}`; |
|
||||
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId: failedId, newState: MessageState.Failed }); |
|
||||
} |
|
||||
} |
|
||||
}, [activeChat, chatType, connection, getMyNodeNum, setMessageState]); |
|
||||
|
|
||||
const renderChatContent = () => { |
|
||||
switch (chatType) { |
|
||||
case MessageType.Broadcast: |
|
||||
return ( |
|
||||
<ChannelChat |
|
||||
messages={getMessages({ |
|
||||
type: MessageType.Broadcast, |
|
||||
channelId: activeChat ?? 0, |
|
||||
})} |
|
||||
/> |
|
||||
); |
|
||||
case MessageType.Direct: |
|
||||
return ( |
|
||||
<ChannelChat |
|
||||
messages={getMessages({ type: MessageType.Direct, nodeA: getMyNodeNum(), nodeB: activeChat })} |
|
||||
/> |
|
||||
); |
|
||||
default: |
|
||||
return ( |
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 p-4"> |
|
||||
Select a channel or node to start messaging. |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const leftSidebar = useMemo(() => ( |
const messageDestination = chatType === "direct" ? activeChat : "broadcast"; |
||||
<Sidebar> |
const messageChannel = chatType === "direct" |
||||
<SidebarSection label="Channels" className="py-2 px-0"> |
? Types.ChannelNumber.Primary |
||||
{filteredChannels?.map((channel) => ( |
: activeChat; |
||||
<SidebarButton |
|
||||
key={channel.index} |
|
||||
count={unreadCounts.get(channel.index)} |
|
||||
label={channel.settings?.name || (channel.index === 0 ? "Primary" : `Ch ${channel.index}`)} |
|
||||
active={activeChat === channel.index && chatType === MessageType.Broadcast} |
|
||||
onClick={() => { |
|
||||
setChatType(MessageType.Broadcast); |
|
||||
setActiveChat(channel.index); |
|
||||
resetUnread(channel.index); |
|
||||
}} |
|
||||
> |
|
||||
<HashIcon size={16} className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")} /> |
|
||||
</SidebarButton> |
|
||||
))} |
|
||||
</SidebarSection> |
|
||||
</Sidebar> |
|
||||
), [filteredChannels, unreadCounts, activeChat, chatType, isCollapsed, setActiveChat, setChatType, resetUnread]); |
|
||||
|
|
||||
const rightSidebar = useMemo(() => ( |
|
||||
<SidebarSection label="" className="px-0 flex flex-col h-full overflow-y-auto"> |
|
||||
<label className="p-2 block"> |
|
||||
<Input |
|
||||
type="text" |
|
||||
placeholder="Search nodes..." |
|
||||
value={searchTerm} |
|
||||
onChange={(e) => setSearchTerm(e.target.value)} |
|
||||
showClearButton={!!searchTerm} |
|
||||
/> |
|
||||
</label> |
|
||||
<div className={cn( |
|
||||
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ", |
|
||||
)}> |
|
||||
{filteredNodes()?.map((node) => ( |
|
||||
<SidebarButton |
|
||||
key={node.num} |
|
||||
preventCollapse={true} |
|
||||
label={node.user?.longName ?? `UNK`} |
|
||||
count={node.unreadCount > 0 ? node.unreadCount : undefined} |
|
||||
active={activeChat === node.num && chatType === MessageType.Direct} |
|
||||
onClick={() => { |
|
||||
setChatType(MessageType.Direct); |
|
||||
setActiveChat(node.num); |
|
||||
resetUnread(node.num); |
|
||||
}}> |
|
||||
<Avatar |
|
||||
text={node.user?.shortName ?? "UNK"} |
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")} |
|
||||
showError={hasNodeError(node.num)} |
|
||||
size="sm" |
|
||||
/> |
|
||||
</SidebarButton> |
|
||||
))} |
|
||||
</div> |
|
||||
</SidebarSection> |
|
||||
), [filteredNodes, searchTerm, activeChat, chatType, setActiveChat, setChatType, resetUnread, hasNodeError]); |
|
||||
return ( |
return ( |
||||
<PageLayout |
<> |
||||
label={`Messages: ${isBroadcast && currentChannel |
<Sidebar> |
||||
? getChannelName(currentChannel) |
<SidebarSection label="Channels"> |
||||
: isDirect && otherNode |
{filteredChannels.map((channel) => ( |
||||
? (otherNode.user?.longName ?? "Unknown") |
<SidebarButton |
||||
: "Select a Chat" |
key={channel.index} |
||||
}`}
|
label={channel.settings?.name.length |
||||
rightBar={rightSidebar} |
? channel.settings?.name |
||||
leftBar={leftSidebar} |
: channel.index === 0 |
||||
actions={isDirect && otherNode |
? "Primary" |
||||
? [ |
: `Ch ${channel.index}`} |
||||
{ |
active={activeChat === channel.index && chatType === "broadcast"} |
||||
key: 'encryption', |
onClick={() => { |
||||
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon, |
setChatType("broadcast"); |
||||
iconClasses: otherNode.user?.publicKey?.length |
setActiveChat(channel.index); |
||||
? "text-green-600" |
}} |
||||
: "text-yellow-300", |
element={<HashIcon size={16} className="mr-2" />} |
||||
onClick() { |
/> |
||||
toast({ |
))} |
||||
title: otherNode.user?.publicKey?.length |
</SidebarSection> |
||||
? "Chat is using PKI encryption." |
<SidebarSection label="Nodes"> |
||||
: "Chat is using PSK encryption.", |
<div className="p-4"> |
||||
}); |
<input |
||||
}, |
type="text" |
||||
}, |
placeholder="Search nodes..." |
||||
] |
value={searchTerm} |
||||
: []} |
onChange={(e) => setSearchTerm(e.target.value)} |
||||
> |
className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900" |
||||
<div className="flex flex-1 flex-col overflow-hidden"> |
/> |
||||
{renderChatContent()} |
</div> |
||||
|
<div className="flex flex-col gap-4"> |
||||
|
{filteredNodes.map((node) => ( |
||||
|
<SidebarButton |
||||
|
key={node.num} |
||||
|
label={node.user?.longName ?? |
||||
|
`!${numberToHexUnpadded(node.num)}`} |
||||
|
active={activeChat === node.num && chatType === "direct"} |
||||
|
onClick={() => { |
||||
|
setChatType("direct"); |
||||
|
setActiveChat(node.num); |
||||
|
}} |
||||
|
element={ |
||||
|
<Avatar |
||||
|
text={node.user?.shortName ?? node.num.toString()} |
||||
|
className={cn(hasNodeError(node.num) && "text-red-500")} |
||||
|
showError={hasNodeError(node.num)} |
||||
|
size="sm" |
||||
|
/> |
||||
|
} |
||||
|
/> |
||||
|
))} |
||||
|
</div> |
||||
|
</SidebarSection> |
||||
|
</Sidebar> |
||||
|
<div className="flex flex-col w-full h-full container mx-auto"> |
||||
|
<PageLayout |
||||
|
className="flex flex-col h-full" |
||||
|
label={`Messages: ${chatType === "broadcast" && currentChannel |
||||
|
? getChannelName(currentChannel) |
||||
|
: chatType === "direct" && nodes.get(activeChat) |
||||
|
? (nodes.get(activeChat)?.user?.longName ?? nodeHex) |
||||
|
: "Loading..." |
||||
|
}`}
|
||||
|
actions={chatType === "direct" |
||||
|
? [ |
||||
|
{ |
||||
|
icon: nodes.get(activeChat)?.user?.publicKey.length |
||||
|
? LockIcon |
||||
|
: LockOpenIcon, |
||||
|
iconClasses: nodes.get(activeChat)?.user?.publicKey.length |
||||
|
? "text-green-600" |
||||
|
: "text-yellow-300", |
||||
|
onClick() { |
||||
|
const targetNode = nodes.get(activeChat)?.num; |
||||
|
if (targetNode === undefined) return; |
||||
|
toast({ |
||||
|
title: nodes.get(activeChat)?.user?.publicKey.length |
||||
|
? "Chat is using PKI encryption." |
||||
|
: "Chat is using PSK encryption.", |
||||
|
}); |
||||
|
}, |
||||
|
}, |
||||
|
] |
||||
|
: []} |
||||
|
> |
||||
|
<div className="flex-1 overflow-y-auto"> |
||||
|
{chatType === "broadcast" && currentChannel && ( |
||||
|
<div className="flex flex-col h-full"> |
||||
|
<div className="flex-1 overflow-y-auto"> |
||||
|
<ChannelChat |
||||
|
key={currentChannel.index} |
||||
|
messages={messages.broadcast.get(currentChannel.index)} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{chatType === "direct" && node && ( |
||||
|
<div className="flex flex-col h-full"> |
||||
|
<div className="flex-1 overflow-y-auto"> |
||||
|
<ChannelChat |
||||
|
key={node.num} |
||||
|
messages={messages.direct.get(node.num)} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
<div className="flex-none dark:bg-slate-900 p-2"> |
<div className="shrink-0 p-4 w-full dark:bg-slate-900"> |
||||
{(isBroadcast || isDirect) ? ( |
|
||||
<MessageInput |
<MessageInput |
||||
to={isDirect ? activeChat : MessageType.Broadcast} |
to={messageDestination} |
||||
onSend={sendText} |
channel={messageChannel} |
||||
maxBytes={200} |
maxBytes={200} |
||||
/> |
/> |
||||
) : ( |
</div> |
||||
<div className="p-4 text-center text-slate-400 italic">Select a chat to send a message.</div> |
</PageLayout> |
||||
)} |
|
||||
</div> |
|
||||
</div> |
</div> |
||||
</PageLayout> |
</> |
||||
); |
); |
||||
}; |
}; |
||||
|
|
||||
|
|||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue