diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 54659fcb..a5d9dc65 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -9,6 +9,7 @@ import { } from "@components/UI/Command.tsx"; import { useAppStore } from "@core/stores/appStore.ts"; import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts"; +import { use } from "chai"; import { useCommandState } from "cmdk"; import { ArrowLeftRightIcon, @@ -32,6 +33,8 @@ import { XCircleIcon, } from "lucide-react"; import { useEffect } from "react"; +import { useMap } from "react-map-gl/maplibre"; +import { useMessageStore } from "@core/stores/messageStore.ts"; export interface Group { label: string; @@ -61,6 +64,7 @@ export const CommandPalette = () => { selectedDevice, } = useAppStore(); const { getDevices } = useDeviceStore(); + const { clearAllMessages } = useMessageStore(); const { setDialogOpen, setActivePage, connection } = useDevice(); const groups: Group[] = [ @@ -117,7 +121,7 @@ export const CommandPalette = () => { return { label: device.nodes.get(device.hardware.myNodeNum)?.user?.longName ?? - device.hardware.myNodeNum.toString(), + device.hardware.myNodeNum.toString(), icon: ( { }, }, { - label: "[WIP] Clear Messages", + label: "Clear All Stored Message", icon: EraserIcon, action() { - alert("This feature is not implemented"); + void clearAllMessages(); }, }, ], diff --git a/src/components/Dialog/NodeOptionsDialog.tsx b/src/components/Dialog/NodeOptionsDialog.tsx index b3d838e7..6a239902 100644 --- a/src/components/Dialog/NodeOptionsDialog.tsx +++ b/src/components/Dialog/NodeOptionsDialog.tsx @@ -13,7 +13,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { TrashIcon } from "lucide-react"; import { Button } from "../UI/Button.tsx"; -import { useMessageStore } from "@core/stores/messageStore.ts"; +import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; export interface NodeOptionsDialogProps { node: Protobuf.Mesh.NodeInfo | undefined; @@ -41,7 +41,7 @@ export const NodeOptionsDialog = ({ (node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK"); function handleDirectMessage() { - setChatType("direct"); + setChatType(MessageType.Direct); setActiveChat(node.num); setActivePage("messages"); } diff --git a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx index bc193646..2661874f 100644 --- a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx +++ b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { renderHook } from '@testing-library/react'; -import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog"; -import { eventBus } from "@core/utils/eventBus"; +import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; +import { eventBus } from "@core/utils/eventBus.ts"; vi.mock('@core/utils/eventBus', () => ({ eventBus: { diff --git a/src/components/PageComponents/Config/Device/Device.test.tsx b/src/components/PageComponents/Config/Device/Device.test.tsx index f9688e90..90bbbffc 100644 --- a/src/components/PageComponents/Config/Device/Device.test.tsx +++ b/src/components/PageComponents/Config/Device/Device.test.tsx @@ -5,11 +5,11 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { Protobuf } from "@meshtastic/core"; -vi.mock('@core/stores/deviceStore', () => ({ +vi.mock('@core/stores/deviceStore.ts', () => ({ useDevice: vi.fn() })); -vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({ +vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({ useUnsafeRolesDialog: vi.fn() })); diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 6e046d11..887789b2 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -55,7 +55,7 @@ export const ChannelChat = ({ ref={scrollContainerRef} className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44" > -
+
{messages?.map((message, index) => ( ({ - useDevice: vi.fn(), +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { MessageInput } from './MessageInput.tsx'; +import { useDevice } from '@core/stores/deviceStore.ts'; +import { useMessageStore } from '@core/stores/messageStore.ts'; +import { debounce } from '@core/utils/debounce.ts'; +import { Types } from "@meshtastic/core"; + +vi.mock('@components/UI/Button.tsx', () => ({ + Button: vi.fn(({ type, className, children, onClick, onSubmit }) => ( + + )), })); -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 30236960..45fdc212 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -4,7 +4,7 @@ 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 { ChatTypes, useMessageStore } from "@core/stores/messageStore.ts"; +import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts"; import { debounce } from "@core/utils/debounce.ts"; export interface MessageInputProps { @@ -13,6 +13,7 @@ export interface MessageInputProps { maxBytes: number; } + export const MessageInput = ({ to, channel, @@ -31,13 +32,13 @@ export const MessageInput = ({ const calculateBytes = (text: string) => new Blob([text]).size; - const chatType = to === 'broadcast' ? ChatTypes.BROADCAST : ChatTypes.DIRECT; + 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: 'ack' }); + setMessageState({ type: chatType, key: activeChat, messageId, newState: MessageState.Ack }); } // deno-lint-ignore no-explicit-any } catch (e: any) { @@ -45,7 +46,7 @@ export const MessageInput = ({ type: chatType, key: activeChat, messageId: e?.id, - newState: 'failed', + newState: MessageState.Failed, }); } }, [channel, connection, setMessageState, to, activeChat, chatType]); @@ -75,7 +76,7 @@ export const MessageInput = ({ return (
-
+