committed by
GitHub
28 changed files with 1143 additions and 610 deletions
@ -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:[email protected]": "[email protected]", |
|||
@ -4492,6 +4493,9 @@ |
|||
"[email protected]": { |
|||
"integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" |
|||
}, |
|||
"[email protected]": { |
|||
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" |
|||
}, |
|||
"[email protected]": { |
|||
"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:[email protected]", |
|||
|
|||
@ -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(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|||
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(<ClearMessagesDialog open={false} onOpenChange={mockOnOpenChange} />); |
|||
expect(screen.queryByText('Clear All Messages')).toBeNull(); |
|||
}); |
|||
|
|||
it('calls onOpenChange with false when the close button is clicked', () => { |
|||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|||
fireEvent.click(screen.getByRole('button', { name: 'Close' })); |
|||
expect(mockOnOpenChange).toHaveBeenCalledWith(false); |
|||
}); |
|||
|
|||
it('calls onOpenChange with false when the dismiss button is clicked', () => { |
|||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|||
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(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|||
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); |
|||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1); |
|||
expect(mockOnOpenChange).toHaveBeenCalledWith(false); |
|||
}); |
|||
}); |
|||
@ -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 ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogClose onClick={handleCloseDialog} /> |
|||
<DialogHeader> |
|||
<DialogTitle className="flex items-center gap-2"> |
|||
<AlertTriangleIcon className="h-5 w-5 text-warning" /> |
|||
Clear All Messages |
|||
</DialogTitle> |
|||
<DialogDescription> |
|||
This action will clear all message history. This cannot be undone. |
|||
Are you sure you want to continue? |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
<DialogFooter className="mt-4"> |
|||
<Button |
|||
variant="outline" |
|||
onClick={handleCloseDialog} |
|||
> |
|||
Dismiss |
|||
</Button> |
|||
<Button |
|||
variant="destructive" |
|||
onClick={() => { |
|||
clearAllMessages(); |
|||
handleCloseDialog(); |
|||
}} |
|||
> |
|||
Clear Messages |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -1,175 +0,0 @@ |
|||
import { memo, useMemo } from "react"; |
|||
import { |
|||
Tooltip, |
|||
TooltipArrow, |
|||
TooltipContent, |
|||
TooltipProvider, |
|||
TooltipTrigger, |
|||
} from "@components/UI/Tooltip.tsx"; |
|||
import { |
|||
type MessageWithState, |
|||
useDeviceStore, |
|||
} from "@core/stores/deviceStore.ts"; |
|||
import { cn } from "@core/utils/cn.ts"; |
|||
import { Avatar } from "@components/UI/Avatar.tsx"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react"; |
|||
|
|||
type MessageStateValue = { |
|||
state: string; |
|||
icon: LucideIcon; |
|||
displayText: string; |
|||
} |
|||
|
|||
type MessageState = MessageWithState["state"]; |
|||
|
|||
interface MessageProps { |
|||
lastMsgSameUser: boolean; |
|||
message: MessageWithState; |
|||
sender: Protobuf.Mesh.NodeInfo; |
|||
} |
|||
|
|||
interface StatusTooltipProps { |
|||
state: MessageState; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
interface StatusIconProps { |
|||
state: MessageState; |
|||
className?: string; |
|||
} |
|||
|
|||
const MESSAGE_STATES: Record<string, MessageStateValue> = { |
|||
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" }, |
|||
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" }, |
|||
FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" }, |
|||
}; |
|||
|
|||
const getMessageState = (state: MessageState): MessageStateValue => { |
|||
switch (state) { |
|||
case MESSAGE_STATES.ACK.state: |
|||
return MESSAGE_STATES.ACK; |
|||
case MESSAGE_STATES.WAITING.state: |
|||
return MESSAGE_STATES.WAITING; |
|||
case MESSAGE_STATES.FAILED.state: |
|||
return MESSAGE_STATES.FAILED; |
|||
default: |
|||
return MESSAGE_STATES.FAILED; |
|||
} |
|||
} |
|||
|
|||
const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( |
|||
<TooltipProvider> |
|||
<Tooltip> |
|||
<TooltipTrigger asChild>{children}</TooltipTrigger> |
|||
<TooltipContent |
|||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95" |
|||
side="top" |
|||
align="center" |
|||
sideOffset={5} |
|||
> |
|||
{getMessageState(state).displayText ?? "An unknown error occurred"}; |
|||
<TooltipArrow className="fill-slate-800" /> |
|||
</TooltipContent> |
|||
</Tooltip> |
|||
</TooltipProvider> |
|||
); |
|||
|
|||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { |
|||
const msgState = getMessageState(state); |
|||
|
|||
const isFailed = msgState.state === 'failed' |
|||
|
|||
const iconClass = cn( |
|||
className, |
|||
"text-slate-500 dark:text-slate-400 size-5 shrink-0" |
|||
); |
|||
|
|||
const Icon = msgState.icon; |
|||
|
|||
return ( |
|||
<StatusTooltip state={state}> |
|||
<Icon |
|||
className={iconClass} |
|||
{...otherProps} |
|||
color={isFailed ? "red" : "currentColor"} |
|||
/> |
|||
</StatusTooltip> |
|||
); |
|||
}; |
|||
|
|||
const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => ( |
|||
<div className={cn("flex items-center gap-2 shrink-0", className)}> |
|||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono"> |
|||
{date.toLocaleDateString()} |
|||
</span> |
|||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono"> |
|||
{date.toLocaleTimeString(undefined, { |
|||
hour: "2-digit", |
|||
minute: "2-digit", |
|||
})} |
|||
</span> |
|||
</div> |
|||
)); |
|||
|
|||
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => { |
|||
const { getDevices } = useDeviceStore(); |
|||
|
|||
const isDeviceUser = useMemo( |
|||
() => |
|||
getDevices() |
|||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) |
|||
.includes(message.from), |
|||
[getDevices, message.from] |
|||
); |
|||
|
|||
const messageUser = sender?.user; |
|||
|
|||
const getMessageTextStyles = (state: MessageState) => { |
|||
const msgState = getMessageState(state); |
|||
const isAcknowledged = msgState.state === 'ack' |
|||
const isFailed = msgState.state === 'failed' |
|||
|
|||
return cn( |
|||
"break-words overflow-hidden", |
|||
isAcknowledged |
|||
? "text-slate-900 dark:text-white" |
|||
: "text-slate-900 dark:text-slate-400", |
|||
isFailed && "text-red-500 dark:text-red-500", |
|||
); |
|||
}; |
|||
|
|||
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]); |
|||
|
|||
|
|||
return ( |
|||
<div className="flex flex-col w-full px-4 justify-start"> |
|||
<div |
|||
className={cn( |
|||
"flex flex-col flex-wrap items-start py-1", |
|||
isDeviceUser && "items-end" |
|||
)} |
|||
> |
|||
<div className="flex items-center gap-2 mb-2"> |
|||
{!lastMsgSameUser && ( |
|||
<div className="flex place-items-center gap-2 mb-1"> |
|||
<Avatar text={messageUser?.shortName ?? "UNK"} /> |
|||
<div className="flex flex-col"> |
|||
<span className="font-medium text-slate-900 dark:text-white truncate"> |
|||
{messageUser?.longName} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
<TimeDisplay date={message.rxTime} /> |
|||
<div className="flex place-items-center gap-2 pb-2"> |
|||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}> |
|||
{message.data} |
|||
</div> |
|||
<StatusIcon state={message.state} /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
}); |
|||
@ -1,152 +1,154 @@ |
|||
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx'; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; |
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
|||
import userEvent from '@testing-library/user-event'; |
|||
|
|||
vi.mock("@core/stores/deviceStore.ts", () => ({ |
|||
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 }) => ( |
|||
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit}> |
|||
{children} |
|||
</button> |
|||
)), |
|||
})); |
|||
|
|||
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 }) => ( |
|||
<input |
|||
autoFocus={autoFocus} |
|||
minLength={minLength} |
|||
name={name} |
|||
placeholder={placeholder} |
|||
value={value} |
|||
onChange={onChange} |
|||
/> |
|||
)), |
|||
})); |
|||
|
|||
vi.mock("@components/UI/Button.tsx", () => ({ |
|||
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button> |
|||
vi.mock('@core/stores/deviceStore.ts', () => ({ |
|||
useDevice: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@components/UI/Input.tsx", () => ({ |
|||
Input: (props: any) => <input {...props} /> |
|||
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: () => <div data-testid="send-icon">Send</div> |
|||
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(() => <svg data-testid="send-icon" />), |
|||
})); |
|||
|
|||
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<typeof vi.fn>).mockReturnValue({ |
|||
connection: { |
|||
sendText: mockSendText, |
|||
}, |
|||
setMessageState: mockSetMessageState, |
|||
messageDraft: "", |
|||
setMessageDraft: mockSetMessageDraft, |
|||
hardware: { |
|||
myNodeNum: 1234567890, |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
it('renders correctly with initial state', () => { |
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
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(<MessageInput {...mockProps} />); |
|||
|
|||
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(<MessageInput {...mockProps} maxBytes={5} />); |
|||
|
|||
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<typeof vi.fn>).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<typeof vi.fn>).mockImplementation((fn) => fn); |
|||
}); |
|||
|
|||
it.skip('sends message and resets form when submitting', async () => { |
|||
try { |
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
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(<MessageInput {...props} />); |
|||
}; |
|||
|
|||
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(<MessageInput {...mockProps} />); |
|||
|
|||
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(<MessageInput {...mockProps} />); |
|||
|
|||
const inputField = screen.getByRole('textbox'); |
|||
|
|||
expect(inputField).toHaveValue('Existing draft'); |
|||
}); |
|||
}); |
|||
@ -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<string>, nodeNum: number) { |
|||
this.channel = data.channel; |
|||
this.to = data.to; |
|||
this.from = data.from; |
|||
this.messageId = data.id; |
|||
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting; |
|||
this.message = data.data; |
|||
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast; |
|||
|
|||
let dateTimestamp = Date.now(); |
|||
if (data.rxTime instanceof Date) { |
|||
const timeValue = data.rxTime.getTime(); |
|||
|
|||
if (!isNaN(timeValue)) { |
|||
dateTimestamp = timeValue; |
|||
} |
|||
} |
|||
else if (data.rxTime != null) { |
|||
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`); |
|||
} |
|||
this.date = dateTimestamp; |
|||
} |
|||
|
|||
toMessage(): Message { |
|||
return { |
|||
channel: this.channel, |
|||
to: this.to, |
|||
from: this.from, |
|||
date: this.date, |
|||
messageId: this.messageId, |
|||
state: this.state, |
|||
message: this.message, |
|||
type: this.type, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
export default PacketToMessageDTO; |
|||
@ -0,0 +1,372 @@ |
|||
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', () => { |
|||
console.log("Mocking zustandIndexDBStorage..."); |
|||
return { |
|||
zustandIndexDBStorage: { |
|||
getItem: vi.fn(async (name: string): Promise<string | null> => { |
|||
console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null); |
|||
return memoryStorage[name] ?? null; |
|||
}), |
|||
setItem: vi.fn(async (name: string, value: string): Promise<void> => { |
|||
console.log(`Mock setItem: ${name}`, value); |
|||
memoryStorage[name] = value; |
|||
}), |
|||
removeItem: vi.fn(async (name: string): Promise<void> => { |
|||
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({}); |
|||
}); |
|||
}); |
|||
|
|||
}); |
|||
@ -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<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; |
|||
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<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, 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, |
|||
}), |
|||
} |
|||
)); |
|||
@ -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<string | null> => { |
|||
return (await get(name)) || null; |
|||
}, |
|||
setItem: async (name: string, value: string): Promise<void> => { |
|||
await set(name, value); |
|||
}, |
|||
removeItem: async (name: string): Promise<void> => { |
|||
await del(name); |
|||
}, |
|||
}; |
|||
Loading…
Reference in new issue