Browse Source
* refactor nodes to getNodes fn. ui updates * fixed several styling issues * fix: message specific styling/overflow * added footer, fixed tests. styling * fix: added theme support back to app component * fix: hide emojis/reactions * fix: added more padding to content element * fix: fixed padding in content element * updated color scheme * fix: more dark mode styling improvements * fix: padding and alignment fixes * fix: prevent left sidebar collapse, added battery component * fix: change scrollbars to "tiny" style, improved message scrolling, fixed bug with message input * message store fixes, ui fixes * fix: disabled message persistance until after releasepull/591/head
committed by
GitHub
78 changed files with 2759 additions and 2023 deletions
|
After Width: | Height: | Size: 1.9 KiB |
@ -0,0 +1,86 @@ |
|||||
|
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; |
||||
@ -1,76 +0,0 @@ |
|||||
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> |
|
||||
); |
|
||||
}; |
|
||||
@ -1,25 +0,0 @@ |
|||||
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,102 +1,97 @@ |
|||||
import { render, screen, fireEvent } from "@testing-library/react"; |
import { render, screen } from "@testing-library/react"; |
||||
import { beforeEach, describe, expect, it, vi, Mock } from "vitest"; |
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts"; |
||||
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; |
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; |
||||
|
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; |
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; |
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; |
||||
import { useMessageStore } from "@core/stores/messageStore.ts"; // Import for mocking
|
import { expect, test, vi, beforeEach, afterEach } from 'vitest'; |
||||
import { useDevice } from "@core/stores/deviceStore.ts"; // Import for mocking
|
|
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
|
|
||||
|
vi.mock("@core/stores/messageStore"); |
||||
vi.mock("@core/stores/messageStore.ts", () => ({ |
vi.mock("./useRefreshKeysDialog"); |
||||
useMessageStore: vi.fn(), |
|
||||
})); |
const mockUseMessageStore = vi.mocked(useMessageStore); |
||||
|
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); |
||||
const mockNodeWithError: Partial<Protobuf.Mesh.NodeInfo> = { |
|
||||
user: { longName: "Test Node Long", shortName: "TNL", id: 456 }, |
const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() }; |
||||
}; |
|
||||
const mockNodes = new Map([[456, mockNodeWithError]]); |
beforeEach(() => { |
||||
const mockNodeErrors = new Map([[123, { node: 456 }]]); |
useDeviceStore.setState(getInitialState(), true); |
||||
|
vi.clearAllMocks(); |
||||
vi.mock("@core/stores/deviceStore.ts", () => ({ |
}); |
||||
useDevice: vi.fn(), |
|
||||
})); |
afterEach(() => { |
||||
|
vi.restoreAllMocks(); |
||||
const mockHandleCloseDialog = vi.fn(); |
}); |
||||
const mockHandleNodeRemove = vi.fn(); |
|
||||
vi.mock("./useRefreshKeysDialog.ts", () => ({ |
test("renders dialog when there is a node error for the active chat", () => { |
||||
useRefreshKeysDialog: vi.fn(() => ({ |
const deviceId = 1; |
||||
handleCloseDialog: mockHandleCloseDialog, |
const nodeWithErrorNum = 12345; |
||||
handleNodeRemove: mockHandleNodeRemove, |
const activeChatNum = nodeWithErrorNum; |
||||
})), |
|
||||
})); |
const deviceStore = useDeviceStore.getState().addDevice(deviceId); |
||||
|
|
||||
describe("RefreshKeysDialog Component", () => { |
deviceStore.addNodeInfo({ |
||||
let onOpenChangeMock: Mock; |
num: nodeWithErrorNum, |
||||
|
user: { |
||||
beforeEach(() => { |
id: nodeWithErrorNum.toString(), |
||||
vi.clearAllMocks(); |
publicKey: new Uint8Array(0), |
||||
onOpenChangeMock = vi.fn(); |
hwModel: Protobuf.Mesh.HardwareModel.HELTEC_V3, |
||||
|
longName: "Problem Node Long", |
||||
vi.mocked(useMessageStore).mockReturnValue({ activeChat: 123 }); |
shortName: "ProbNode", |
||||
vi.mocked(useDevice).mockReturnValue({ |
isLicensed: false, |
||||
nodeErrors: mockNodeErrors, |
macaddr: new Uint8Array(0) |
||||
nodes: mockNodes, |
}, |
||||
}); |
lastHeard: Date.now() / 1000, |
||||
vi.mocked(useRefreshKeysDialog).mockReturnValue({ |
snr: 10 |
||||
handleCloseDialog: mockHandleCloseDialog, |
} as Protobuf.Mesh.NodeInfo); |
||||
handleNodeRemove: mockHandleNodeRemove, |
|
||||
}); |
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, |
||||
}); |
}); |
||||
|
|
||||
it("should render the dialog with dynamic content when open and data is available", () => { |
render( |
||||
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); |
<DeviceContext.Provider value={updatedDeviceState}> |
||||
|
<RefreshKeysDialog open onOpenChange={vi.fn()} /> |
||||
expect(screen.getByText(`Keys Mismatch - ${mockNodeWithError?.user?.longName}`)).toBeInTheDocument(); |
</DeviceContext.Provider> |
||||
expect(screen.getByText(new RegExp(`${mockNodeWithError?.user?.longName}.*${mockNodeWithError?.user?.shortName}`))).toBeInTheDocument(); |
); |
||||
expect(screen.getByRole('button', { name: /request new keys/i })).toBeInTheDocument(); |
|
||||
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); |
|
||||
expect(screen.getByRole('button', { name: /Close/i })).toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
it("should call handleNodeRemove when 'Request New Keys' button is clicked", () => { |
expect(screen.getByText(/Keys Mismatch - Problem Node Long/)).toBeInTheDocument(); |
||||
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); |
expect(screen.getByText(/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/)).toBeInTheDocument(); |
||||
fireEvent.click(screen.getByRole('button', { name: /request new keys/i })); |
expect(screen.getByRole("button", { name: "Request New Keys" })).toBeInTheDocument(); |
||||
expect(mockHandleNodeRemove).toHaveBeenCalledTimes(1); |
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument(); |
||||
}); |
}); |
||||
|
|
||||
it("should call handleCloseDialog when 'Dismiss' button is clicked", () => { |
test("does not render dialog if no error exists for active chat", () => { |
||||
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); |
const deviceId = 1; |
||||
fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); |
const activeChatNum = 54321; |
||||
expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1); |
|
||||
}); |
|
||||
|
|
||||
it("should call handleCloseDialog when the explicit DialogClose button is clicked", () => { |
useDeviceStore.getState().addDevice(deviceId); |
||||
render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); |
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i })); // Use the aria-label
|
|
||||
expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1); |
|
||||
}); |
|
||||
|
|
||||
|
const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); |
||||
|
if (!currentDeviceState) throw new Error("Device not found"); |
||||
|
|
||||
it("should not render the dialog when open is false", () => { |
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); |
||||
render(<RefreshKeysDialog open={false} onOpenChange={onOpenChangeMock} />); |
mockUseRefreshKeysDialog.mockReturnValue({ |
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); |
handleCloseDialog: vi.fn(), |
||||
|
handleNodeRemove: vi.fn(), |
||||
}); |
}); |
||||
|
|
||||
it("should render null if nodeErrorNum is not found for activeChat", () => { |
const { container } = render( |
||||
vi.mocked(useDevice).mockReturnValue({ |
<DeviceContext.Provider value={currentDeviceState}> |
||||
nodeErrors: new Map(), |
<RefreshKeysDialog open onOpenChange={vi.fn()} /> |
||||
nodes: mockNodes, |
</DeviceContext.Provider> |
||||
}); |
); |
||||
const { container } = render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); |
|
||||
expect(container.firstChild).toBeNull(); |
|
||||
}); |
|
||||
|
|
||||
it("should render null if nodeWithError is not found for nodeErrorNum.node", () => { |
expect(container.firstChild).toBeNull(); |
||||
vi.mocked(useDevice).mockReturnValue({ |
}); |
||||
nodeErrors: mockNodeErrors, |
|
||||
nodes: new Map(), |
|
||||
}); |
|
||||
const { container } = render(<RefreshKeysDialog open onOpenChange={onOpenChangeMock} />); |
|
||||
expect(container.firstChild).toBeNull(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|||||
@ -1,28 +1,27 @@ |
|||||
import { useCallback } from "react"; |
import { useCallback } from "react"; |
||||
import { useDevice } from "@core/stores/deviceStore.ts"; |
import { useDevice } from "@core/stores/deviceStore.ts"; |
||||
import { useMessageStore } from "@core/stores/messageStore.ts"; |
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
||||
|
|
||||
export function useRefreshKeysDialog() { |
export function useRefreshKeysDialog() { |
||||
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); |
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); |
||||
const { activeChat } = useMessageStore(); |
const { activeChat } = useMessageStore(); |
||||
|
|
||||
|
const handleCloseDialog = useCallback(() => { |
||||
|
setDialogOpen('refreshKeys', false); |
||||
|
}, [setDialogOpen]); |
||||
|
|
||||
const handleNodeRemove = useCallback(() => { |
const handleNodeRemove = useCallback(() => { |
||||
const nodeWithError = getNodeError(activeChat); |
const nodeWithError = getNodeError(activeChat); |
||||
if (!nodeWithError) { |
if (!nodeWithError) { |
||||
return; |
return; |
||||
} |
} |
||||
clearNodeError(activeChat); |
clearNodeError(activeChat); |
||||
handleCloseDialog();; |
handleCloseDialog(); |
||||
return removeNode(nodeWithError?.node); |
return removeNode(nodeWithError?.node); |
||||
}, [activeChat, clearNodeError, setDialogOpen, removeNode]); |
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]); |
||||
|
|
||||
const handleCloseDialog = useCallback(() => { |
|
||||
setDialogOpen('refreshKeys', false); |
|
||||
}, [setDialogOpen]) |
|
||||
|
|
||||
return { |
return { |
||||
handleCloseDialog, |
handleCloseDialog, |
||||
handleNodeRemove |
handleNodeRemove |
||||
}; |
}; |
||||
|
|
||||
} |
} |
||||
@ -1,72 +1,77 @@ |
|||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; |
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; |
||||
import type { Message as MessageType } from "@core/stores/messageStore.ts"; |
|
||||
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?: MessageType[]; |
messages?: Message[]; |
||||
} |
} |
||||
|
|
||||
const EmptyState = () => ( |
const EmptyState = () => ( |
||||
<div className="flex flex-col place-content-center place-items-center p-8 text-gray-500 dark:text-gray-400"> |
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400"> |
||||
<InboxIcon className="h-8 w-8 mb-2" /> |
<InboxIcon className="mb-2 h-8 w-8" /> |
||||
<span className="text-sm">No Messages</span> |
<span className="text-sm">No Messages</span> |
||||
</div> |
</div> |
||||
); |
); |
||||
|
|
||||
export const ChannelChat = ({ |
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { |
||||
messages = [], |
|
||||
}: ChannelChatProps) => { |
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null); |
const messagesEndRef = useRef<HTMLDivElement>(null); |
||||
const scrollContainerRef = useRef<HTMLDivElement>(null); |
const scrollContainerRef = useRef<HTMLUListElement>(null); |
||||
|
const userScrolledUpRef = useRef(false); |
||||
|
|
||||
const scrollToBottom = useCallback(() => { |
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { |
||||
|
requestAnimationFrame(() => { |
||||
|
messagesEndRef.current?.scrollIntoView({ behavior }); |
||||
|
}); |
||||
|
}, []); |
||||
|
|
||||
|
useEffect(() => { |
||||
const scrollContainer = scrollContainerRef.current; |
const scrollContainer = scrollContainerRef.current; |
||||
if (scrollContainer) { |
if (!scrollContainer) return; |
||||
const isNearBottom = |
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; |
||||
scrollContainer.scrollHeight - |
|
||||
scrollContainer.scrollTop - |
|
||||
scrollContainer.clientHeight < |
|
||||
100; |
|
||||
|
|
||||
if (isNearBottom) { |
if (isScrolledToBottom || !userScrolledUpRef.current) { |
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
scrollToBottom('smooth'); |
||||
} |
|
||||
} |
} |
||||
}, []); |
}, [messages, scrollToBottom]); |
||||
|
|
||||
useEffect(() => { |
useEffect(() => { |
||||
scrollToBottom(); |
const scrollContainer = scrollContainerRef.current; |
||||
}, [scrollToBottom, messages]); |
const handleScroll = () => { |
||||
|
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-col h-full"> |
<div className="flex flex-1 flex-col items-center justify-center"> |
||||
<div className="flex-1 flex items-center justify-center"> |
<EmptyState /> |
||||
<EmptyState /> |
<div ref={messagesEndRef} /> |
||||
</div> |
|
||||
</div> |
</div> |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
return ( |
return ( |
||||
<div className="flex flex-col h-full"> |
<ul |
||||
<div |
ref={scrollContainerRef} |
||||
ref={scrollContainerRef} |
className="flex flex-col flex-grow overflow-y-auto px-3 py-2" |
||||
className="flex-1 overflow-y-auto py-4" |
> |
||||
> |
<div className="flex-grow" /> |
||||
<div className="flex flex-col justify-end min-h-full space-y-4"> |
|
||||
{messages?.map((message) => { |
{messages?.map((message) => ( |
||||
return ( |
<MessageItem |
||||
<MessageItem |
key={message.messageId ?? `${message.from}-${message.date}`} |
||||
key={message?.messageId} |
message={message} |
||||
message={message} |
/> |
||||
/> |
))} |
||||
); |
|
||||
})} |
<div ref={messagesEndRef} className="h-px" /> |
||||
<div ref={messagesEndRef} className="h-0 w-full" /> |
</ul> |
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
); |
||||
}; |
}; |
||||
@ -1,77 +1,126 @@ |
|||||
|
import React from 'react'; |
||||
import { cn } from "@core/utils/cn.ts"; |
import { cn } from "@core/utils/cn.ts"; |
||||
import { AlignLeftIcon, type LucideIcon } from "lucide-react"; |
import { 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; |
||||
noPadding?: boolean; |
actions?: ActionItem[]; |
||||
children: React.ReactNode; |
children: React.ReactNode; |
||||
className?: string; |
leftBar?: React.ReactNode; |
||||
actions?: { |
rightBar?: React.ReactNode; |
||||
icon: LucideIcon; |
noPadding?: boolean; |
||||
iconClasses?: string; |
leftBarClassName?: string; |
||||
onClick: () => void; |
rightBarClassName?: string; |
||||
disabled?: boolean; |
topBarClassName?: string; |
||||
isLoading?: boolean; |
contentClassName?: string; |
||||
}[]; |
|
||||
} |
} |
||||
|
|
||||
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="relative flex h-full w-full flex-col"> |
<div className="flex flex-1 bg-background text-foreground overflow-hidden"> |
||||
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4"> |
{/* Left Sidebar */} |
||||
<button |
{leftBar && ( |
||||
type="button" |
<aside |
||||
className="pl-4 transition-all hover:text-accent md:hidden" |
className={cn( |
||||
|
"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 |
||||
|
)} |
||||
> |
> |
||||
<AlignLeftIcon /> |
{/* Header Content */} |
||||
</button> |
<div className="flex flex-1 items-center justify-between min-w-0"> |
||||
<div className="flex flex-1 items-center justify-between px-4 md:px-0"> |
<span className="text-lg font-medium text-foreground truncate px-2"> |
||||
<div className="flex w-full items-center"> |
{label} |
||||
<span className="w-full text-lg font-medium">{label}</span> |
</span> |
||||
<div className="flex justify-end space-x-4"> |
<div className="flex items-center space-x-3 md:space-x-4 shrink-0"> |
||||
{actions?.map((action) => ( |
{actions?.map((action) => ( |
||||
<button |
<button |
||||
key={action.icon.displayName} |
key={action.key} |
||||
type="button" |
type="button" |
||||
disabled={action?.disabled} |
disabled={action.disabled || action.isLoading} |
||||
className="transition-all hover:text-accent" |
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed" |
||||
onClick={action.onClick} |
onClick={action.onClick} |
||||
|
aria-label={action.ariaLabel || `Action ${action.key}`} |
||||
|
aria-disabled={action.disabled} |
||||
|
aria-busy={action.isLoading} |
||||
> |
> |
||||
{action?.isLoading ? <Spinner /> : ( |
<div className="mr-6"> |
||||
<action.icon |
{action.isLoading ? ( |
||||
className={action.iconClasses} |
<Spinner size="md" /> |
||||
aria-disabled={action.disabled} |
) : ( |
||||
/> |
<action.icon |
||||
)} |
className={cn("h-5 w-5", action.iconClasses)} |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
</button> |
</button> |
||||
))} |
))} |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
</div> |
</header> |
||||
</div> |
|
||||
<div |
<main |
||||
className={cn( |
className={cn( |
||||
"flex h-full w-full flex-col overflow-y-auto", |
"flex-1 flex flex-col", |
||||
!noPadding && "pl-3 pr-3 ", |
"overflow-hidden", |
||||
className |
!noPadding && "px-2", |
||||
)} |
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,148 +1,241 @@ |
|||||
|
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 { |
||||
BatteryMediumIcon, |
CircleChevronLeft, |
||||
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 { useState } from "react"; |
import { cn } from "@core/utils/cn.ts"; |
||||
|
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, nodes, metadata } = useDevice(); |
const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice(); |
||||
const myNode = nodes.get(hardware.myNodeNum); |
const { setCommandPaletteOpen } = useAppStore(); |
||||
|
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: "Messages", |
name: `Nodes (${Math.max(getNodesLength() - 1, 0)})`, |
||||
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(nodes.size - 1, 0)})`, |
|
||||
icon: UsersIcon, |
icon: UsersIcon, |
||||
page: "nodes", |
page: "nodes", |
||||
}, |
}, |
||||
]; |
]; |
||||
|
|
||||
return showSidebar |
return ( |
||||
? ( |
<div |
||||
<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"> |
className={cn( |
||||
|
'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 px-8 py-6"> |
<div className="flex flex-col items-center justify-center py-6"> |
||||
<Spinner /> |
<Spinner /> |
||||
<Subtle className="mt-2">Loading device info...</Subtle> |
<Subtle |
||||
|
className={cn( |
||||
|
'mt-4 transition-opacity duration-300', |
||||
|
isCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible' |
||||
|
)} |
||||
|
> |
||||
|
Loading... |
||||
|
</Subtle> |
||||
</div> |
</div> |
||||
) : ( |
) : ( |
||||
<> |
<> |
||||
<div className="flex justify-between px-8 pt-6"> |
<div |
||||
<div> |
className={cn( |
||||
<span className="text-lg font-medium"> |
'flex place-items-center gap-2', |
||||
{myNode.user?.shortName ?? "UNK"} |
isCollapsed && 'justify-center' |
||||
</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" |
||||
className="transition-all hover:text-accent" |
aria-label="Edit device name" |
||||
|
className="p-1 rounded transition-colors hover:text-accent" |
||||
onClick={() => setDialogOpen("deviceName", true)} |
onClick={() => setDialogOpen("deviceName", true)} |
||||
> |
> |
||||
<EditIcon size={16} /> |
<PenLine size={22} /> |
||||
</button> |
</button> |
||||
<button type="button" onClick={() => setShowSidebar(false)}> |
<ThemeSwitcher /> |
||||
<SidebarCloseIcon size={24} /> |
<button |
||||
|
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> |
||||
}; |
); |
||||
|
}; |
||||
@ -0,0 +1,81 @@ |
|||||
|
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,19 +1,42 @@ |
|||||
import { Heading } from "../Typography/Heading.tsx"; |
import React from "react"; |
||||
|
import { cn } from "@core/utils/cn.ts"; |
||||
|
import { Heading } from "@components/UI/Typography/Heading.tsx"; |
||||
|
import { useSidebar } from "@core/stores/sidebarStore.tsx"; |
||||
|
|
||||
export interface SidebarSectionProps { |
interface SidebarSectionProps { |
||||
label: string; |
label: string; |
||||
subheader?: string; |
|
||||
children: React.ReactNode; |
children: React.ReactNode; |
||||
|
className?: string; |
||||
} |
} |
||||
|
|
||||
export const SidebarSection = ({ |
export const SidebarSection = ({ |
||||
label: title, |
label, |
||||
children, |
children, |
||||
}: SidebarSectionProps) => ( |
className, |
||||
<div className="px-4 py-2"> |
}: SidebarSectionProps) => { |
||||
<Heading as="h4" className="mb-3 ml-2"> |
const { isCollapsed } = useSidebar(); |
||||
{title} |
return ( |
||||
</Heading> |
<div className={cn( |
||||
<div className="space-y-1">{children}</div> |
"py-2", |
||||
</div> |
isCollapsed ? 'px-0' : 'px-4', |
||||
); |
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> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,40 @@ |
|||||
|
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,371 +0,0 @@ |
|||||
import { describe, it, expect, beforeEach, vi } from 'vitest'; |
|
||||
import { |
|
||||
useMessageStore, |
|
||||
MessageType, |
|
||||
MessageState, |
|
||||
type Message, |
|
||||
} from './messageStore.ts'; |
|
||||
|
|
||||
let memoryStorage: Record<string, string> = {}; |
|
||||
|
|
||||
vi.mock('./storage/indexDB.ts', () => { |
|
||||
return { |
|
||||
zustandIndexDBStorage: { |
|
||||
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 = 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, true); |
|
||||
}); |
|
||||
|
|
||||
it('should have correct initial state', () => { |
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.direct).toEqual({}); |
|
||||
expect(state.messages.broadcast).toEqual({}); |
|
||||
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 structure', () => { |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.direct[myNodeNum]).toBeDefined(); |
|
||||
expect(state.messages.direct[myNodeNum][otherNodeNum1]).toBeDefined(); |
|
||||
expect( |
|
||||
state.messages.direct[myNodeNum][otherNodeNum1][directMessageToOther1.messageId], |
|
||||
).toEqual(directMessageToOther1); |
|
||||
}); |
|
||||
|
|
||||
it('should save a broadcast message with correct structure', () => { |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.broadcast[broadcastChannel]).toBeDefined(); |
|
||||
expect( |
|
||||
state.messages.broadcast[broadcastChannel][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(); |
|
||||
|
|
||||
// Direct msg 1 (me -> other1)
|
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1); |
|
||||
// Direct msg 2 (other1 -> me)
|
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1); |
|
||||
// Broadcast msg 1
|
|
||||
expect(state.messages.broadcast[broadcastChannel]?.[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(MessageType.Broadcast, { |
|
||||
myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice
|
|
||||
channel: 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(MessageType.Broadcast, { |
|
||||
myNodeNum: myNodeNum, |
|
||||
channel: 99 |
|
||||
}); |
|
||||
expect(messages).toEqual([]); |
|
||||
}); |
|
||||
|
|
||||
it('should return combined direct messages for a specific chat (pair), sorted by date', () => { |
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { |
|
||||
myNodeNum: myNodeNum, |
|
||||
otherNodeNum: 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(MessageType.Direct, { |
|
||||
myNodeNum: myNodeNum, |
|
||||
otherNodeNum: 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(MessageType.Direct, { |
|
||||
myNodeNum: myNodeNum, |
|
||||
otherNodeNum: 999 |
|
||||
}); |
|
||||
expect(messages).toEqual([]); |
|
||||
}); |
|
||||
|
|
||||
it('should return combined direct messages when myNodeNum and otherNodeNum are provided', () => { |
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { |
|
||||
myNodeNum: myNodeNum, // Keep this
|
|
||||
otherNodeNum: otherNodeNum1 |
|
||||
}); |
|
||||
expect(messages).toHaveLength(2); |
|
||||
expect(messages[0]).toEqual(directMessageToOther1); |
|
||||
expect(messages[1]).toEqual(directMessageFromOther1); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
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 sent BY ME', () => { |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Direct, |
|
||||
key: otherNodeNum1, |
|
||||
messageId: directMessageToOther1.messageId, |
|
||||
newState: MessageState.Ack, |
|
||||
}); |
|
||||
const message = useMessageStore.getState().messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]; |
|
||||
expect(message?.state).toBe(MessageState.Ack); |
|
||||
}); |
|
||||
|
|
||||
it('should update state for a direct message received FROM OTHER', () => { |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Direct, |
|
||||
key: otherNodeNum1, |
|
||||
messageId: directMessageFromOther1.messageId, |
|
||||
newState: MessageState.Failed, |
|
||||
}); |
|
||||
const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]; |
|
||||
expect(message?.state).toBe(MessageState.Failed); |
|
||||
}); |
|
||||
|
|
||||
it('should update state for a broadcast message', () => { |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Broadcast, |
|
||||
key: broadcastChannel, |
|
||||
messageId: broadcastMessage1.messageId, |
|
||||
newState: MessageState.Ack, |
|
||||
}); |
|
||||
const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]; |
|
||||
expect(message?.state).toBe(MessageState.Ack); |
|
||||
}); |
|
||||
|
|
||||
it('should warn if message is not found', () => { |
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); |
|
||||
useMessageStore.getState().setMessageState({ |
|
||||
type: MessageType.Direct, |
|
||||
key: otherNodeNum1, |
|
||||
messageId: 999, |
|
||||
newState: MessageState.Ack, |
|
||||
}); |
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message not found for state update')); |
|
||||
warnSpy.mockRestore(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
|
|
||||
describe('clearMessageByMessageId', () => { |
|
||||
beforeEach(() => { |
|
||||
useMessageStore.getState().setNodeNum(myNodeNum); |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: 1011, date: Date.now() + 50 }); |
|
||||
}); |
|
||||
|
|
||||
it('should delete a specific direct message (sent by me)', () => { |
|
||||
const messageIdToDelete = directMessageToOther1.messageId; |
|
||||
useMessageStore.getState().clearMessageByMessageId({ |
|
||||
type: MessageType.Direct, |
|
||||
from: myNodeNum, |
|
||||
to: otherNodeNum1, |
|
||||
messageId: messageIdToDelete |
|
||||
}); |
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[messageIdToDelete]).toBeUndefined(); |
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined(); |
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toBeDefined(); |
|
||||
}); |
|
||||
|
|
||||
it('should delete a specific direct message (sent by other)', () => { |
|
||||
const messageIdToDelete = directMessageFromOther1.messageId; |
|
||||
useMessageStore.getState().clearMessageByMessageId({ |
|
||||
type: MessageType.Direct, |
|
||||
from: otherNodeNum1, |
|
||||
to: myNodeNum, |
|
||||
messageId: messageIdToDelete |
|
||||
}); |
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[messageIdToDelete]).toBeUndefined(); |
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toBeDefined(); |
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined(); |
|
||||
}); |
|
||||
|
|
||||
it('should delete a specific broadcast message', () => { |
|
||||
const messageIdToDelete = broadcastMessage1.messageId; |
|
||||
useMessageStore.getState().clearMessageByMessageId({ |
|
||||
type: MessageType.Broadcast, |
|
||||
channel: broadcastChannel, |
|
||||
messageId: messageIdToDelete |
|
||||
}); |
|
||||
const state = useMessageStore.getState(); |
|
||||
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined(); |
|
||||
}); |
|
||||
|
|
||||
it('should clean up empty to/from/channel objects', () => { |
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, from: otherNodeNum1, to: myNodeNum, messageId: directMessageFromOther1.messageId }); |
|
||||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed
|
|
||||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed
|
|
||||
|
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channel: broadcastChannel, messageId: broadcastMessage1.messageId }); |
|
||||
expect(useMessageStore.getState().messages.broadcast[broadcastChannel]).toBeUndefined(); // Channel level removed
|
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
describe('Drafts', () => { |
|
||||
const draftKey = otherNodeNum1; |
|
||||
const draftMessage = 'This is a draft'; |
|
||||
|
|
||||
it('should set and get a draft', () => { |
|
||||
useMessageStore.getState().setDraft(draftKey, draftMessage); |
|
||||
expect(useMessageStore.getState().draft.get(draftKey)).toBe(draftMessage); |
|
||||
expect(useMessageStore.getState().getDraft(draftKey)).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(draftKey, draftMessage); |
|
||||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(true); |
|
||||
useMessageStore.getState().clearDraft(draftKey); |
|
||||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(false); |
|
||||
expect(useMessageStore.getState().getDraft(draftKey)).toBe(''); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
describe('deleteAllMessages', () => { |
|
||||
it('should clear all direct and broadcast messages', () => { |
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|
||||
expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0); |
|
||||
expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0); |
|
||||
|
|
||||
useMessageStore.getState().deleteAllMessages(); |
|
||||
|
|
||||
expect(useMessageStore.getState().messages.direct).toEqual({}); |
|
||||
expect(useMessageStore.getState().messages.broadcast).toEqual({}); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
}); |
|
||||
@ -1,234 +0,0 @@ |
|||||
import { create } from 'zustand'; |
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'; |
|
||||
import { produce } from 'immer'; |
|
||||
import { Types } from '@meshtastic/core'; |
|
||||
import { zustandIndexDBStorage } from "./storage/indexDB.ts"; |
|
||||
|
|
||||
export enum MessageState { |
|
||||
Ack = "ack", |
|
||||
Waiting = "waiting", |
|
||||
Failed = "failed", |
|
||||
} |
|
||||
|
|
||||
export enum MessageType { |
|
||||
Direct = "direct", |
|
||||
Broadcast = "broadcast", |
|
||||
} |
|
||||
|
|
||||
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; |
|
||||
} |
|
||||
|
|
||||
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>; |
|
||||
|
|
||||
export interface MessageStore { |
|
||||
messages: { |
|
||||
direct: Record<number, Record<number, Record<number, Message>>>; |
|
||||
broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
|
|
||||
}; |
|
||||
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; |
|
||||
getNodeNum: () => number; |
|
||||
setActiveChat: (chat: number) => void; |
|
||||
setChatType: (type: MessageType) => void; |
|
||||
saveMessage: (message: Message) => void; |
|
||||
setMessageState: (params: { |
|
||||
type: MessageType; |
|
||||
// For Direct: Represents the *other* node number involved in the chat.
|
|
||||
// For Broadcast: Represents the channel number.
|
|
||||
key: number; |
|
||||
messageId: number; |
|
||||
newState?: MessageState; |
|
||||
}) => void; |
|
||||
getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[]; |
|
||||
getDraft: (key: Types.Destination) => string; |
|
||||
setDraft: (key: Types.Destination, message: string) => void; |
|
||||
deleteAllMessages: () => void; |
|
||||
clearMessageByMessageId: (params: { |
|
||||
type: MessageType; |
|
||||
from?: number; |
|
||||
to?: number; |
|
||||
channel?: number; |
|
||||
messageId: number |
|
||||
}) => void; |
|
||||
clearDraft: (key: Types.Destination) => void; |
|
||||
} |
|
||||
|
|
||||
const CURRENT_STORE_VERSION = 0; |
|
||||
|
|
||||
export const useMessageStore = create<MessageStore>()( |
|
||||
persist( |
|
||||
(set, get) => ({ |
|
||||
messages: { |
|
||||
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
|
|
||||
broadcast: {}, |
|
||||
}, |
|
||||
draft: new Map<number, string>(), |
|
||||
activeChat: 0, |
|
||||
chatType: MessageType.Broadcast, |
|
||||
nodeNum: 0, |
|
||||
setNodeNum: (nodeNum) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.nodeNum = nodeNum; |
|
||||
})); |
|
||||
}, |
|
||||
getNodeNum: () => get().nodeNum, |
|
||||
setActiveChat: (chat) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.activeChat = chat; |
|
||||
})); |
|
||||
}, |
|
||||
setChatType: (type) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
state.chatType = type; |
|
||||
})); |
|
||||
}, |
|
||||
saveMessage: (message) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
if (message.type === MessageType.Direct) { |
|
||||
const sender = Number(message.from); |
|
||||
const recipient = Number(message.to); |
|
||||
|
|
||||
if (!state.messages.direct[sender]) { |
|
||||
state.messages.direct[sender] = {}; |
|
||||
} |
|
||||
if (!state.messages.direct[sender][recipient]) { |
|
||||
state.messages.direct[sender][recipient] = {}; |
|
||||
} |
|
||||
state.messages.direct[sender][recipient][message.messageId] = message; |
|
||||
|
|
||||
} else if (message.type === MessageType.Broadcast) { |
|
||||
const channel = Number(message.channel); |
|
||||
if (!state.messages.broadcast[channel]) { |
|
||||
state.messages.broadcast[channel] = {}; |
|
||||
} |
|
||||
state.messages.broadcast[channel][message.messageId] = message; |
|
||||
} |
|
||||
})); |
|
||||
}, |
|
||||
setMessageState: ({ |
|
||||
type, |
|
||||
key, |
|
||||
messageId, |
|
||||
newState = MessageState.Ack, |
|
||||
}) => { |
|
||||
set( |
|
||||
produce((state: MessageStore) => { |
|
||||
let message: Message | undefined; |
|
||||
|
|
||||
if (type === MessageType.Broadcast) { |
|
||||
const channel = key; |
|
||||
message = state.messages.broadcast?.[channel]?.[messageId]; |
|
||||
} else if (type === MessageType.Direct) { |
|
||||
const otherNodeNum = key; |
|
||||
const myNodeNum = state.nodeNum; |
|
||||
|
|
||||
message = state.messages.direct?.[myNodeNum]?.[otherNodeNum]?.[messageId]; |
|
||||
|
|
||||
if (!message) { |
|
||||
message = state.messages.direct?.[otherNodeNum]?.[myNodeNum]?.[messageId]; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (message) { |
|
||||
message.state = newState; |
|
||||
} else { |
|
||||
console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`); |
|
||||
} |
|
||||
}), |
|
||||
); |
|
||||
}, |
|
||||
getMessages: (type, options) => { |
|
||||
const state = get(); |
|
||||
|
|
||||
if (type === MessageType.Broadcast && options.channel !== undefined) { |
|
||||
const messageMap = state.messages.broadcast[options.channel] ?? {}; |
|
||||
return Object.values(messageMap).sort((a, b) => a.date - b.date); |
|
||||
} |
|
||||
|
|
||||
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) { |
|
||||
const myNodeNum = options.myNodeNum; |
|
||||
const otherNodeNum = options.otherNodeNum; |
|
||||
|
|
||||
// Messages sent BY ME TO OTHER
|
|
||||
const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {}; |
|
||||
const sentByMe = Object.values(sentByMeMap); |
|
||||
|
|
||||
// Messages sent BY OTHER TO ME
|
|
||||
const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {}; |
|
||||
const sentByOther = Object.values(sentByOtherMap); |
|
||||
|
|
||||
// Merge and sort chronologically
|
|
||||
return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date); |
|
||||
} |
|
||||
return []; |
|
||||
}, |
|
||||
clearMessageByMessageId: ({ type, from, to, channel, messageId }) => { |
|
||||
set(produce((state: MessageStore) => { |
|
||||
if (type === MessageType.Broadcast && channel !== undefined) { |
|
||||
const messageMap = state.messages.broadcast[channel]; |
|
||||
if (messageMap?.[messageId]) { |
|
||||
delete messageMap[messageId]; |
|
||||
if (Object.keys(messageMap).length === 0) { |
|
||||
delete state.messages.broadcast[channel]; |
|
||||
} |
|
||||
} |
|
||||
} else if (type === MessageType.Direct && from !== undefined && to !== undefined) { |
|
||||
const messageMap = state.messages.direct?.[from]?.[to]; |
|
||||
if (messageMap?.[messageId]) { |
|
||||
delete messageMap[messageId]; |
|
||||
if (Object.keys(messageMap).length === 0) { |
|
||||
delete state.messages.direct[from][to]; |
|
||||
if (Object.keys(state.messages.direct[from]).length === 0) { |
|
||||
delete state.messages.direct[from]; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
console.warn("clearMessageByMessageId called without sufficient identifiers for type", type); |
|
||||
} |
|
||||
})); |
|
||||
}, |
|
||||
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 = {}; |
|
||||
state.messages.broadcast = {}; |
|
||||
})); |
|
||||
} |
|
||||
}), |
|
||||
{ |
|
||||
name: 'meshtastic-message-store', |
|
||||
storage: createJSONStorage(() => zustandIndexDBStorage), |
|
||||
version: CURRENT_STORE_VERSION, |
|
||||
partialize: (state) => ({ |
|
||||
messages: state.messages, |
|
||||
nodeNum: state.nodeNum, |
|
||||
}), |
|
||||
} |
|
||||
)); |
|
||||
@ -0,0 +1,212 @@ |
|||||
|
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,
|
||||
|
// }),
|
||||
|
// })
|
||||
|
) |
||||
@ -0,0 +1,486 @@ |
|||||
|
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); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,70 @@ |
|||||
|
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, |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
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; |
||||
|
}; |
||||
Loading…
Reference in new issue