diff --git a/deno.lock b/deno.lock index 7ff40a0e..ddd91281 100644 --- a/deno.lock +++ b/deno.lock @@ -45,6 +45,7 @@ "npm:crypto-random-string@5": "5.0.0", "npm:gzipper@^8.2.1": "8.2.1", "npm:happy-dom@^17.4.4": "17.4.4", + "npm:idb-keyval@^6.2.1": "6.2.1", "npm:immer@^10.1.1": "10.1.1", "npm:js-cookie@^3.0.5": "3.0.5", "npm:lucide-react@0.486": "0.486.0_react@19.1.0", @@ -4492,6 +4493,9 @@ "https-browserify@1.0.0": { "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" }, + "idb-keyval@6.2.1": { + "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" + }, "idb@7.1.1": { "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" }, @@ -6491,6 +6495,7 @@ "npm:crypto-random-string@5", "npm:gzipper@^8.2.1", "npm:happy-dom@^17.4.4", + "npm:idb-keyval@^6.2.1", "npm:immer@^10.1.1", "npm:js-cookie@^3.0.5", "npm:lucide-react@0.486", diff --git a/package.json b/package.json index 3ebda1bb..22308979 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "crypto-random-string": "^5.0.0", + "idb-keyval": "^6.2.1", "immer": "^10.1.1", "js-cookie": "^3.0.5", "lucide-react": "^0.486.0", diff --git a/src/components/CommandPalette/index.tsx b/src/components/CommandPalette/index.tsx index 289f07fc..70699d81 100644 --- a/src/components/CommandPalette/index.tsx +++ b/src/components/CommandPalette/index.tsx @@ -219,10 +219,10 @@ export const CommandPalette = () => { }, }, { - label: "[WIP] Clear Messages", + label: "Clear All Stored Message", icon: EraserIcon, action() { - alert("This feature is not implemented"); + setDialogOpen("clearMessages", true); }, }, ], diff --git a/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx b/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx new file mode 100644 index 00000000..cf317589 --- /dev/null +++ b/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useMessageStore } from "@core/stores/messageStore.ts"; +import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx"; + +vi.mock('@core/stores/messageStore.ts', () => ({ + useMessageStore: vi.fn(() => ({ + clearAllMessages: vi.fn(), + })), +})); + +describe('ClearMessagesDialog', () => { + const mockOnOpenChange = vi.fn(); + const mockClearAllMessages = vi.fn(); + + beforeEach(() => { + vi.mocked(useMessageStore).mockReturnValue({ clearAllMessages: mockClearAllMessages }); + mockOnOpenChange.mockClear(); + mockClearAllMessages.mockClear(); + }); + + it('renders the dialog when open is true', () => { + render(); + expect(screen.getByText('Clear All Messages')).toBeVisible(); + expect(screen.getByText(/This action will clear all message history./)).toBeVisible(); + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeVisible(); + }); + + it('does not render the dialog when open is false', () => { + render(); + expect(screen.queryByText('Clear All Messages')).toBeNull(); + }); + + it('calls onOpenChange with false when the close button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + it('calls onOpenChange with false when the dismiss button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + it('calls clearAllMessages and onOpenChange with false when the clear messages button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); + expect(mockClearAllMessages).toHaveBeenCalledTimes(1); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); +}); \ No newline at end of file diff --git a/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx b/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx new file mode 100644 index 00000000..4bb27b82 --- /dev/null +++ b/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx @@ -0,0 +1,63 @@ + +import { Button } from "@components/UI/Button.tsx"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { AlertTriangleIcon } from "lucide-react"; +import { useMessageStore } from "@core/stores/messageStore.ts"; + +export interface ClearMessagesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const ClearMessagesDialog = ({ + open, + onOpenChange, +}: ClearMessagesDialogProps) => { + const { clearAllMessages } = useMessageStore(); + const handleCloseDialog = () => { + onOpenChange(false); + }; + + return ( + + + + + + + Clear All Messages + + + This action will clear all message history. This cannot be undone. + Are you sure you want to continue? + + + + + + + + + ); +}; diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index 38e8ff3d..941ba2da 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -10,6 +10,8 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx"; +import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx"; + export const DialogManager = () => { const { channels, config, dialog, setDialogOpen } = useDevice(); @@ -84,6 +86,12 @@ export const DialogManager = () => { setDialogOpen("rebootOTA", open); }} /> + { + setDialogOpen("clearMessages", open); + }} + /> ); }; diff --git a/src/components/Dialog/NodeOptionsDialog.tsx b/src/components/Dialog/NodeOptionsDialog.tsx index a152d74e..6a239902 100644 --- a/src/components/Dialog/NodeOptionsDialog.tsx +++ b/src/components/Dialog/NodeOptionsDialog.tsx @@ -13,6 +13,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { TrashIcon } from "lucide-react"; import { Button } from "../UI/Button.tsx"; +import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; export interface NodeOptionsDialogProps { node: Protobuf.Mesh.NodeInfo | undefined; @@ -29,23 +30,23 @@ export const NodeOptionsDialog = ({ const { setNodeNumToBeRemoved, setNodeNumDetails, - setChatType, - setActiveChat, } = useAppStore(); + const { setChatType, setActiveChat } = useMessageStore(); + + if (!node) return null; + const longName = node?.user?.longName ?? (node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown"); const shortName = node?.user?.shortName ?? (node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK"); function handleDirectMessage() { - if (!node) return; - setChatType("direct"); + setChatType(MessageType.Direct); setActiveChat(node.num); setActivePage("messages"); } function handleRequestPosition() { - if (!node) return; toast({ title: "Requesting position, please wait...", }); @@ -58,7 +59,6 @@ export const NodeOptionsDialog = ({ } function handleTraceroute() { - if (!node) return; toast({ title: "Sending Traceroute, please wait...", }); @@ -92,7 +92,7 @@ export const NodeOptionsDialog = ({ key="remove" variant="destructive" onClick={() => { - setNodeNumToBeRemoved(node.num); + setNodeNumToBeRemoved(node?.num); setDialogOpen("nodeRemoval", true); }} > @@ -103,7 +103,7 @@ export const NodeOptionsDialog = ({
+ )), })); -vi.mock("@core/utils/debounce.ts", () => ({ - debounce: (fn: () => void) => fn, +vi.mock('@components/UI/Input.tsx', () => ({ + Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => ( + + )), })); -vi.mock("@components/UI/Button.tsx", () => ({ - Button: ({ children, ...props }: { children: React.ReactNode }) => +vi.mock('@core/stores/deviceStore.ts', () => ({ + useDevice: vi.fn(), })); -vi.mock("@components/UI/Input.tsx", () => ({ - Input: (props: any) => +vi.mock('@core/stores/messageStore.ts', () => ({ + useMessageStore: vi.fn(), + MessageState: { + Ack: 'ack', + Waiting: 'waiting', + Failed: 'failed', + }, + MessageType: { + Direct: 'direct', + Broadcast: 'broadcast', + }, })); -vi.mock("lucide-react", () => ({ - SendIcon: () =>
Send
+vi.mock('@core/utils/debounce.ts', () => ({ + debounce: vi.fn((fn) => fn), })); -// TODO: getting an error with this test -describe('MessageInput Component', () => { - const mockProps = { - to: "broadcast" as const, - channel: 0 as const, - maxBytes: 100, - }; +vi.mock('lucide-react', () => ({ + SendIcon: vi.fn(() => ), +})); - const mockSetMessageDraft = vi.fn(); +describe('MessageInput', () => { const mockSetMessageState = vi.fn(); - const mockSendText = vi.fn().mockResolvedValue(123); + const mockSetActiveChat = vi.fn(); + const mockSetDraft = vi.fn(); + const mockGetDraft = vi.fn(); + const mockClearDraft = vi.fn(); + const mockSendText = vi.fn(); beforeEach(() => { - vi.clearAllMocks(); - - (useDevice as Mock).mockReturnValue({ + (useDevice as ReturnType).mockReturnValue({ connection: { sendText: mockSendText, }, - setMessageState: mockSetMessageState, - messageDraft: "", - setMessageDraft: mockSetMessageDraft, - hardware: { - myNodeNum: 1234567890, - }, }); - }); - - it('renders correctly with initial state', () => { - render(); - - expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument(); - expect(screen.getByTestId('send-icon')).toBeInTheDocument(); - expect(screen.getByText('0/100')).toBeInTheDocument(); - }); - - it('updates local draft and byte count when typing', () => { - render(); - - const inputField = screen.getByPlaceholderText('Enter Message'); - fireEvent.change(inputField, { target: { value: 'Hello' } }) - - expect(screen.getByText('5/100')).toBeInTheDocument(); - expect(inputField).toHaveValue('Hello'); - expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello'); - }); - - it.skip('does not allow input exceeding max bytes', () => { - render(); - - const inputField = screen.getByPlaceholderText('Enter Message'); - - expect(screen.getByText('0/100')).toBeInTheDocument(); - - userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p') + (useMessageStore as unknown as ReturnType).mockReturnValue({ + setMessageState: mockSetMessageState, + activeChat: 123, + setDraft: mockSetDraft, + getDraft: mockGetDraft, + clearDraft: mockClearDraft, + }); - expect(screen.getByText('100/100')).toBeInTheDocument(); - expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'); + mockSetMessageState.mockClear(); + mockSetActiveChat.mockClear(); + mockSetDraft.mockClear(); + mockGetDraft.mockClear(); + mockClearDraft.mockClear(); + mockSendText.mockClear(); + (debounce as ReturnType).mockImplementation((fn) => fn); }); - it.skip('sends message and resets form when submitting', async () => { - try { - render(); - - const inputField = screen.getByPlaceholderText('Enter Message'); - const submitButton = screen.getByText('Send'); - - fireEvent.change(inputField, { target: { value: 'Test Message' } }); - fireEvent.click(submitButton); - - const form = screen.getByRole('form'); - fireEvent.submit(form); - - expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0); - - await waitFor(() => { - expect(mockSetMessageState).toHaveBeenCalledWith( - 'broadcast', - 0, - 'broadcast', - 1234567890, - 123, - 'ack' - ); + const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => { + render(); + }; + it.skip('sends text message and updates state to Ack on submit', async () => { + renderComponent({ to: 2, channel: 3, maxBytes: 256 }); + const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; + fireEvent.change(inputElement, { target: { value: 'Hello' } }); + const formElement = screen.getByRole('form'); + fireEvent.submit(formElement); + + await waitFor(() => { + expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3); + expect(mockSetMessageState).toHaveBeenCalledWith({ + type: 'direct', + key: 123, + messageId: undefined, + newState: 'ack', }); - - expect(inputField).toHaveValue(''); - expect(screen.getByText('0/100')).toBeInTheDocument(); - expect(mockSetMessageDraft).toHaveBeenCalledWith(''); - } catch (e) { - console.error(e); - } + expect(mockClearDraft).toHaveBeenCalledWith(2); + expect(inputElement.value).toBe(''); + expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); + }); }); - it('prevents sending empty messages', () => { - render(); - const form = screen.getByPlaceholderText('Enter Message') - fireEvent.submit(form); - - expect(mockSendText).not.toHaveBeenCalled(); + it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => { + renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 }); + const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; + fireEvent.change(inputElement, { target: { value: 'Broadcast message' } }); + const formElement = screen.getByRole('form'); + fireEvent.submit(formElement); + + await waitFor(() => { + expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5); + expect(mockSetMessageState).toHaveBeenCalledWith({ + type: 'broadcast', + key: 123, + messageId: undefined, + newState: 'ack', + }); + expect(mockClearDraft).toHaveBeenCalledWith('broadcast'); + expect(inputElement.value).toBe(''); + expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); + }); }); - it('initializes with existing message draft', () => { - (useDevice as Mock).mockReturnValue({ - connection: { - sendText: mockSendText, - }, - setMessageState: mockSetMessageState, - messageDraft: "Existing draft", - setMessageDraft: mockSetMessageDraft, - isQueueingMessages: false, - queueStatus: { free: 10 }, - hardware: { - myNodeNum: 1234567890, - }, + it('updates state to Failed if sendText throws an error', async () => { + mockSendText.mockRejectedValue({ id: 456 }); + renderComponent({ to: 3, channel: 1, maxBytes: 256 }); + const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; + fireEvent.change(inputElement, { target: { value: 'Error message' } }); + const formElement = screen.getByRole('form'); + fireEvent.submit(formElement); + + await waitFor(() => { + expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1); + expect(mockSetMessageState).toHaveBeenCalledWith({ + type: 'direct', + key: 123, + messageId: 456, + newState: 'failed', + }); + expect(mockClearDraft).toHaveBeenCalledWith(3); + expect(inputElement.value).toBe(''); + expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); }); - - render(); - - const inputField = screen.getByRole('textbox'); - - expect(inputField).toHaveValue('Existing draft'); }); }); \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index b885afaf..a2383d0a 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -1,10 +1,11 @@ -import { debounce } from "@core/utils/debounce.ts"; import { Button } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import type { Types } from "@meshtastic/core"; import { SendIcon } from "lucide-react"; import { startTransition, useCallback, useMemo, useState } from "react"; +import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts"; +import { debounce } from "@core/utils/debounce.ts"; export interface MessageInputProps { to: Types.Destination; @@ -17,57 +18,41 @@ export const MessageInput = ({ channel, maxBytes, }: MessageInputProps) => { - const { - connection, - setMessageState, - messageDraft, - setMessageDraft, - isQueueingMessages, - queueStatus, - hardware, - } = useDevice(); - const myNodeNum = hardware.myNodeNum; - const [localDraft, setLocalDraft] = useState(messageDraft); + const { connection } = useDevice(); + const { setMessageState, activeChat, setDraft, getDraft, clearDraft } = useMessageStore(); + + const [localDraft, setLocalDraft] = useState(getDraft(to)); const [messageBytes, setMessageBytes] = useState(0); const debouncedSetMessageDraft = useMemo( - () => debounce(setMessageDraft, 300), - [setMessageDraft], + () => debounce((value: string) => setDraft(to, value), 300), + [setDraft, to] ); - // sends the message to the selected destination - const sendText = useCallback( - async (message: string) => { + const calculateBytes = (text: string) => new Blob([text]).size; - await connection - ?.sendText(message, to, true, channel) - .then((id: number) => - setMessageState( - to === "broadcast" ? "broadcast" : "direct", - channel, - to as number, - myNodeNum, - id, - "ack", - ) - ) - .catch((e: Types.PacketError) => - setMessageState( - to === "broadcast" ? "broadcast" : "direct", - channel, - to as number, - myNodeNum, - e.id, - e.error, - ) - ); - }, - [channel, connection, myNodeNum, setMessageState, to, queueStatus], - ); + const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct; + + const sendText = useCallback(async (message: string) => { + try { + const messageId = await connection?.sendText(message, to, true, channel); + if (messageId !== undefined) { + setMessageState({ type: chatType, key: activeChat, messageId, newState: MessageState.Ack }); + } + // deno-lint-ignore no-explicit-any + } catch (e: any) { + setMessageState({ + type: chatType, + key: activeChat, + messageId: e?.id, + newState: MessageState.Failed, + }); + } + }, [channel, connection, setMessageState, to, activeChat, chatType]); const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; - const byteLength = new Blob([newValue]).size; + const byteLength = calculateBytes(newValue); if (byteLength <= maxBytes) { setLocalDraft(newValue); @@ -76,26 +61,22 @@ export const MessageInput = ({ } }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!localDraft.trim()) return; + + startTransition(() => { + sendText(localDraft.trim()); + setLocalDraft(""); + clearDraft(to); + setMessageBytes(0); + }); + }; + return (
-
{ - // prevent user from sending blank/empty message - if (localDraft === "") return; - const message = formData.get("messageInput") as string; - startTransition(() => { - if (!isQueueingMessages) { - sendText(message); - setLocalDraft(""); - setMessageDraft(""); - setMessageBytes(0); - } - - }); - }} - > -
+ +
+ -
diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index 384aaefc..f448bb01 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -11,7 +11,8 @@ import { Avatar } from "@components/UI/Avatar.tsx"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { ReactNode, useMemo } from "react"; -import { Message, MessageState } from "@core/services/types.ts"; +import { Message, MessageState, useMessageStore } from "@core/stores/messageStore.ts"; +import { Protobuf } from "@meshtastic/js"; interface MessageProps { lastMsgSameUser: boolean; @@ -25,24 +26,20 @@ interface MessageStatus { } const MESSAGE_STATUS: Record = { - ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 }, - waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis }, - failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle }, + [MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2 }, + [MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis }, + [MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle }, }; const getMessageStatus = (state: MessageState): MessageStatus => - MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle }; + MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle }; + const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => ( {children} - + {status.displayText} @@ -51,76 +48,105 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ); const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => { - const isFailed = status.state === "failed"; - const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className); + const isFailed = status.state === MessageState.Failed; + const iconClass = cn("w-4 h-4 shrink-0", className); const Icon = status.icon; - return ( - + ); }; -const getMessageTextStyles = (status: MessageStatus) => { - const isAcknowledged = status.state === "ack"; - const isFailed = status.state === "failed"; - +const getMessageTextStyles = (status: MessageState, isDeviceUser: boolean) => { + const isFailed = status === MessageState.Failed; return cn( - "break-words overflow-hidden", - isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400", - isFailed && "text-red-500 dark:text-red-500", + "break-words overflow-hidden whitespace-pre-wrap flex items-center gap-1.5", + isFailed && (isDeviceUser ? "text-red-500" : "text-red-600 dark:text-red-500") ); }; -const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => ( -
- {date.toLocaleDateString()} - - {date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} - -
-); + +const TimeDisplay = ({ date, className }: { date: number; className?: string }) => { + const _date = new Date(date); + const locale = 'en-US'; // TODO: this should be dynamic based on user settings + return ( +
+ + {_date?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true })} + + {/* TODO: Conditionally show date for older messages? */} +
+ ); +}; export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => { + const myNodeNum = useMessageStore((state) => state.nodeNum); + const { getDevices } = useDeviceStore(); - const isDeviceUser = useMemo( - () => - getDevices() - .map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) - .includes(message.from), - [getDevices, message.from], - ); + const isDeviceUser = message.from === myNodeNum; - const messageUser = message?.from - ? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from) - : null; + const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => { + if (message?.from === null || message?.from === undefined) return null; + for (const device of getDevices()) { + if (device.nodes.has(message.from)) { + return device.nodes.get(message.from) ?? null; + } + } + return null; + }, [getDevices, message.from]); + + const fallbackName = `${message.from}`; + const longName = messageUser?.user?.longName; + const shortName = messageUser?.user?.shortName ?? fallbackName.slice(0, 2).toUpperCase(); + const displayName = isDeviceUser ? "You" : (longName || fallbackName); + + const messageContainerClass = cn( + "flex flex-col w-full px-4 justify-start", + !lastMsgSameUser ? "pt-3" : "pt-0.5" + ); + const alignmentClass = cn( + "flex flex-col flex-wrap w-full", + isDeviceUser ? "items-end" : "items-start" + ); + const bubbleBaseStyle = "flex flex-col max-w-[75%] rounded-lg px-3 py-1.5 text-sm shadow-md"; + const sentBubbleStyle = "bg-gradient-to-br from-blue-600 to-blue-700 dark:from-blue-500 dark:to-blue-600 text-white"; + const receivedBubbleStyle = "bg-slate-200 dark:bg-slate-500 text-slate-900 dark:text-white"; + const timeStatusColor = isDeviceUser ? "text-blue-100 dark:text-blue-200" : "text-slate-500 dark:text-slate-300"; const messageStatus = getMessageStatus(message.state); - const messageTextClass = getMessageTextStyles(messageStatus); + return ( -
-
-
- {!lastMsgSameUser && ( -
- -
- - {messageUser?.user?.longName} - -
-
- )} -
- -
-
{message.message}
- +
+
+ + {/* Show only if not consecutive message AND not sent by self */} + {!lastMsgSameUser && ( +
+ + + {displayName} + +
+ )} + +
+
+ +
+ +
+ {message.message || Empty message} + {isDeviceUser && } +
); }; + diff --git a/src/core/dto/PacketToMessageDTO.ts b/src/core/dto/PacketToMessageDTO.ts new file mode 100644 index 00000000..fdfcc356 --- /dev/null +++ b/src/core/dto/PacketToMessageDTO.ts @@ -0,0 +1,51 @@ +import type { Types } from "@meshtastic/js"; +import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts"; + +class PacketToMessageDTO { + channel: Types.ChannelNumber; + to: number; + from: number; + date: number; // (timestamp ms) + messageId: number; + state: MessageState; + message: string; + type: MessageType; + + constructor(data: Types.PacketMetadata, nodeNum: number) { + this.channel = data.channel; + this.to = data.to; + this.from = data.from; + this.messageId = data.id; + this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting; + this.message = data.data; + this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast; + + let dateTimestamp = Date.now(); + if (data.rxTime instanceof Date) { + const timeValue = data.rxTime.getTime(); + + if (!isNaN(timeValue)) { + dateTimestamp = timeValue; + } + } + else if (data.rxTime != null) { + console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`); + } + this.date = dateTimestamp; + } + + toMessage(): Message { + return { + channel: this.channel, + to: this.to, + from: this.from, + date: this.date, + messageId: this.messageId, + state: this.state, + message: this.message, + type: this.type, + }; + } +} + +export default PacketToMessageDTO; \ No newline at end of file diff --git a/src/core/stores/appStore.ts b/src/core/stores/appStore.ts index 4b286e6f..d7cc67fb 100644 --- a/src/core/stores/appStore.ts +++ b/src/core/stores/appStore.ts @@ -1,4 +1,3 @@ -import { Types } from "@meshtastic/core"; import { produce } from "immer"; import { create } from "zustand"; @@ -25,14 +24,11 @@ interface AppState { id: number; num: number; }[]; - rasterSources: RasterSource[]; commandPaletteOpen: boolean; nodeNumToBeRemoved: number; connectDialogOpen: boolean; nodeNumDetails: number; - activeChat: number; - chatType: "broadcast" | "direct"; errors: ErrorState[]; setRasterSources: (sources: RasterSource[]) => void; @@ -45,8 +41,6 @@ interface AppState { setNodeNumToBeRemoved: (nodeNum: number) => void; setConnectDialogOpen: (open: boolean) => void; setNodeNumDetails: (nodeNum: number) => void; - setActiveChat: (chat: number) => void; - setChatType: (type: "broadcast" | "direct") => void; // Error management hasErrors: () => boolean; @@ -67,8 +61,6 @@ export const useAppStore = create()((set, get) => ({ connectDialogOpen: false, nodeNumToBeRemoved: 0, nodeNumDetails: 0, - activeChat: Types.ChannelNumber.Primary, - chatType: "broadcast", errors: [], setRasterSources: (sources: RasterSource[]) => { @@ -127,14 +119,6 @@ export const useAppStore = create()((set, get) => ({ set(() => ({ nodeNumDetails: nodeNum, })), - setActiveChat: (chat) => - set(() => ({ - activeChat: chat, - })), - setChatType: (type) => - set(() => ({ - chatType: type, - })), hasErrors: () => { const state = get(); return state.errors.length > 0; diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index c6e74551..58736738 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -10,7 +10,7 @@ export interface MessageWithState extends Types.PacketMetadata { state: MessageState; } -export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error; +export type MessageState = "ack" | "waiting" | 'failed'; export interface ProcessPacketParams { from: number; @@ -29,11 +29,8 @@ export type DialogVariant = | "pkiBackup" | "nodeDetails" | "unsafeRoles" - | "refreshKeys"; - -type QueueStatus = { - res: number, free: number, maxlen: number -} + | "refreshKeys" + | "clearMessages"; type NodeError = { node: number; @@ -51,10 +48,6 @@ export interface Device { hardware: Protobuf.Mesh.MyNodeInfo; nodes: Map; metadata: Map; - messages: { - direct: Map; - broadcast: Map; - }; traceroutes: Map< number, Types.PacketMetadata[] @@ -67,8 +60,6 @@ export interface Device { // currentMetrics: Protobuf.DeviceMetrics; pendingSettingsChanges: boolean; messageDraft: string; - queueStatus: QueueStatus, - isQueueingMessages: boolean, dialog: { import: boolean; QR: boolean; @@ -81,6 +72,7 @@ export interface Device { nodeDetails: boolean; unsafeRoles: boolean; refreshKeys: boolean; + clearMessages: boolean; }; @@ -100,25 +92,15 @@ export interface Device { addUser: (user: Types.PacketMetadata) => void; addPosition: (position: Types.PacketMetadata) => void; addConnection: (connection: MeshDevice) => void; - addMessage: (message: MessageWithState) => void; addTraceRoute: ( traceroute: Types.PacketMetadata, ) => void; addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void; removeNode: (nodeNum: number) => void; - setMessageState: ( - type: "direct" | "broadcast", - channelIndex: Types.ChannelNumber, - to: number, - from: number, - messageId: number, - state: MessageState, - ) => void; setDialogOpen: (dialog: DialogVariant, open: boolean) => void; getDialogOpen: (dialog: DialogVariant) => boolean; processPacket: (data: ProcessPacketParams) => void; setMessageDraft: (message: string) => void; - setQueueStatus: (status: QueueStatus) => void; setNodeError: (nodeNum: number, error: string) => void; clearNodeError: (nodeNum: number) => void; getNodeError: (nodeNum: number) => NodeError | undefined; @@ -153,19 +135,11 @@ export const useDeviceStore = createStore((set, get) => ({ hardware: create(Protobuf.Mesh.MyNodeInfoSchema), nodes: new Map(), metadata: new Map(), - messages: { - direct: new Map(), - broadcast: new Map(), - }, traceroutes: new Map(), connection: undefined, activePage: "messages", activeNode: 0, waypoints: [], - queueStatus: { - res: 0, free: 0, maxlen: 0 - }, - isQueueingMessages: false, dialog: { import: false, QR: false, @@ -510,31 +484,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - addMessage: (message) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const messageGroup = device.messages[message.type]; - const messageIndex = message.type === "direct" - ? message.from === device.hardware.myNodeNum - ? message.to - : message.from - : message.channel; - const messages = messageGroup.get(messageIndex); - - if (messages) { - messages.push(message); - messageGroup.set(messageIndex, messages); - } else { - messageGroup.set(messageIndex, [message]); - } - }), - ); - }, - addMetadata: (from, metadata) => { set( produce((draft) => { @@ -575,43 +524,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - setMessageState: ( - type: "direct" | "broadcast", - channelIndex: Types.ChannelNumber, - to: number, - from: number, - messageId: number, - state: MessageState, - ) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const messageGroup = device.messages[type]; - - const messageIndex = type === "direct" - ? from === device.hardware.myNodeNum ? to : from - : channelIndex; - const messages = messageGroup.get(messageIndex); - - if (!messages) { - return; - } - - messageGroup.set( - messageIndex, - messages.map((msg) => { - if (msg.id === messageId) { - msg.state = state; - } - return msg; - }), - ); - }), - ); - }, setDialogOpen: (dialog: DialogVariant, open: boolean) => { set( produce((draft) => { @@ -667,17 +579,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - setQueueStatus: (status: QueueStatus) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.queueStatus = status; - device.queueStatus.free >= 10 ? true : false - } - }), - ); - }, setNodeError: (nodeNum, error) => { set( produce((draft) => { diff --git a/src/core/stores/messageStore.test.ts b/src/core/stores/messageStore.test.ts new file mode 100644 index 00000000..e8fc70c7 --- /dev/null +++ b/src/core/stores/messageStore.test.ts @@ -0,0 +1,372 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + useMessageStore, + MessageType, + MessageState, + type Message, +} from './messageStore.ts'; + +let memoryStorage: Record = {}; + +vi.mock('./storage/indexDB.ts', () => { + console.log("Mocking zustandIndexDBStorage..."); + return { + zustandIndexDBStorage: { + getItem: vi.fn(async (name: string): Promise => { + console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null); + return memoryStorage[name] ?? null; + }), + setItem: vi.fn(async (name: string, value: string): Promise => { + console.log(`Mock setItem: ${name}`, value); + memoryStorage[name] = value; + }), + removeItem: vi.fn(async (name: string): Promise => { + console.log(`Mock removeItem: ${name}`); + 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 empty array if myNodeNum is not provided for direct messages', () => { + const messages = useMessageStore.getState().getMessages(MessageType.Direct, { + otherNodeNum: otherNodeNum1 + }); + 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 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, + sender: myNodeNum, + recipient: 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, + sender: otherNodeNum1, + recipient: 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 recipient/sender/channel objects', () => { + useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, sender: otherNodeNum1, recipient: 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('clearAllMessages', () => { + 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().clearAllMessages(); + + expect(useMessageStore.getState().messages.direct).toEqual({}); + expect(useMessageStore.getState().messages.broadcast).toEqual({}); + }); + }); + +}); diff --git a/src/core/stores/messageStore.ts b/src/core/stores/messageStore.ts new file mode 100644 index 00000000..fdb9ad9c --- /dev/null +++ b/src/core/stores/messageStore.ts @@ -0,0 +1,234 @@ +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 extends MessageBase { + type: T; +} + +export type Message = GenericMessage | GenericMessage; + +export interface MessageStore { + messages: { + direct: Record>>; + broadcast: Record>; // channel -> messageId -> Message + }; + draft: Map; + 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; + clearAllMessages: () => void; + clearMessageByMessageId: (params: { + type: MessageType; + sender?: number; + recipient?: number; + channel?: number; + messageId: number + }) => void; + clearDraft: (key: Types.Destination) => void; +} + +const CURRENT_STORE_VERSION = 0; + +export const useMessageStore = create()( + persist( + (set, get) => ({ + messages: { + direct: {}, // Record>> + broadcast: {}, + }, + draft: new Map(), + 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, sender, recipient, 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 && sender !== undefined && recipient !== undefined) { + const messageMap = state.messages.direct?.[sender]?.[recipient]; + if (messageMap?.[messageId]) { + delete messageMap[messageId]; + if (Object.keys(messageMap).length === 0) { + delete state.messages.direct[sender][recipient]; + if (Object.keys(state.messages.direct[sender]).length === 0) { + delete state.messages.direct[sender]; + } + } + } + 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); + })); + }, + clearAllMessages: () => { + 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, + }), + } + )); \ No newline at end of file diff --git a/src/core/stores/storage/indexDB.ts b/src/core/stores/storage/indexDB.ts new file mode 100644 index 00000000..b6f08d50 --- /dev/null +++ b/src/core/stores/storage/indexDB.ts @@ -0,0 +1,14 @@ +import { StateStorage } from "zustand/middleware"; +import { get, set, del } from "idb-keyval"; + +export const zustandIndexDBStorage: StateStorage = { + getItem: async (name: string): Promise => { + return (await get(name)) || null; + }, + setItem: async (name: string, value: string): Promise => { + await set(name, value); + }, + removeItem: async (name: string): Promise => { + await del(name); + }, +}; \ No newline at end of file diff --git a/src/core/subscriptions.ts b/src/core/subscriptions.ts index 0ad39019..b1d19901 100644 --- a/src/core/subscriptions.ts +++ b/src/core/subscriptions.ts @@ -1,9 +1,12 @@ import type { Device } from "@core/stores/deviceStore.ts"; import { MeshDevice, Protobuf } from "@meshtastic/core"; +import type { MessageStore } from "@core/stores/messageStore.ts"; +import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts"; export const subscribeAll = ( device: Device, connection: MeshDevice, + messageStore: MessageStore ) => { let myNodeNum = 0; @@ -51,6 +54,7 @@ export const subscribeAll = ( connection.events.onMyNodeInfo.subscribe((nodeInfo) => { device.setHardware(nodeInfo); + messageStore.setNodeNum(nodeInfo.myNodeNum); myNodeNum = nodeInfo.myNodeNum; }); @@ -81,10 +85,10 @@ export const subscribeAll = ( connection.events.onMessagePacket.subscribe((messagePacket) => { - device.addMessage({ - ...messagePacket, - state: messagePacket.from !== myNodeNum ? "ack" : "waiting", - }); + // incoming and outgoing messages are handled by this event listener + const dto = new PacketToMessageDTO(messagePacket, myNodeNum); + const message = dto.toMessage(); + messageStore.saveMessage(message); }); connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => { @@ -105,9 +109,6 @@ export const subscribeAll = ( }); }); - connection.events.onQueueStatus.subscribe((queueStatus) => { - device.setQueueStatus(queueStatus); - }); connection.events.onRoutingPacket.subscribe((routingPacket) => { if (routingPacket.data.variant.case === "errorReason") { diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index b492cd0d..214b1e96 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -1,4 +1,3 @@ -import { useAppStore } from "../core/stores/appStore.ts"; import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; @@ -14,11 +13,14 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; import { useState } from "react"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { cn } from "@core/utils/cn.ts"; +import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; export const MessagesPage = () => { - const { channels, nodes, hardware, messages, hasNodeError } = useDevice(); - const { activeChat, chatType, setActiveChat, setChatType } = useAppStore(); + const { channels, nodes, hardware, hasNodeError } = useDevice(); + const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore() + const { toast } = useToast(); const [searchTerm, setSearchTerm] = useState(""); + const filteredNodes = Array.from(nodes.values()).filter((node) => { if (node.num === hardware.myNodeNum) return false; const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; @@ -29,14 +31,15 @@ export const MessagesPage = () => { (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, ); const currentChannel = channels.get(activeChat); - const { toast } = useToast(); - const node = nodes.get(activeChat); - const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown"; - const messageDestination = chatType === "direct" ? activeChat : "broadcast"; - const messageChannel = chatType === "direct" - ? Types.ChannelNumber.Primary - : activeChat; + const otherNode = nodes.get(activeChat); + + const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown"; + + const isDirect = chatType === MessageType.Direct; + const isBroadcast = chatType === MessageType.Broadcast; + + const currentChat = { type: chatType, id: activeChat }; return ( <> @@ -50,9 +53,8 @@ export const MessagesPage = () => { : channel.index === 0 ? "Primary" : `Ch ${channel.index}`} - active={activeChat === channel.index && chatType === "broadcast"} onClick={() => { - setChatType("broadcast"); + setChatType(MessageType.Broadcast); setActiveChat(channel.index); }} element={} @@ -70,21 +72,21 @@ export const MessagesPage = () => { />
- {filteredNodes.map((node) => ( + {filteredNodes.map((otherNode) => ( { - setChatType("direct"); - setActiveChat(node.num); + setChatType(MessageType.Direct); + setActiveChat(otherNode.num); }} element={ } @@ -96,13 +98,13 @@ export const MessagesPage = () => {
{ : []} >
- {chatType === "broadcast" && currentChannel && ( + {isBroadcast && currentChannel && (
)} - {chatType === "direct" && node && ( + {isDirect && otherNode && (
@@ -150,8 +153,9 @@ export const MessagesPage = () => {
diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts index 089c4c52..083acd8d 100644 --- a/src/tests/setupTests.ts +++ b/src/tests/setupTests.ts @@ -1,11 +1,9 @@ -import { expect, afterEach } from 'vitest'; +import { afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; -import * as matchers from '@testing-library/jest-dom/matchers'; +import { enableMapSet } from "immer"; import "@testing-library/jest-dom"; -// Enable auto mocks for our UI components -//vi.mock('@components/UI/Dialog.tsx'); -//vi.mock('@components/UI/Typography/Link.tsx'); +enableMapSet(); globalThis.ResizeObserver = class { observe() { } diff --git a/vitest.config.ts b/vitest.config.ts index cbf600bc..aaed988c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,8 @@ import path from "node:path"; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config' +import { enableMapSet } from "immer"; +enableMapSet(); export default defineConfig({ plugins: [ react(),