Browse Source

add enums, improve tests, add styling

pull/536/head
Dan Ditomaso 1 year ago
parent
commit
a56ac84186
  1. 8
      src/components/CommandPalette.tsx
  2. 4
      src/components/Dialog/NodeOptionsDialog.tsx
  3. 4
      src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
  4. 4
      src/components/PageComponents/Config/Device/Device.test.tsx
  5. 2
      src/components/PageComponents/Messages/ChannelChat.tsx
  6. 230
      src/components/PageComponents/Messages/MessageInput.test.tsx
  7. 11
      src/components/PageComponents/Messages/MessageInput.tsx
  8. 135
      src/components/PageComponents/Messages/MessageItem.tsx
  9. 32
      src/core/dto/PacketToMessageDTO.ts
  10. 290
      src/core/stores/messageStore.test.ts
  11. 93
      src/core/stores/messageStore.ts
  12. 24
      src/pages/Messages.tsx

8
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[] = [
@ -221,10 +225,10 @@ export const CommandPalette = () => {
},
},
{
label: "[WIP] Clear Messages",
label: "Clear All Stored Message",
icon: EraserIcon,
action() {
alert("This feature is not implemented");
void clearAllMessages();
},
},
],

4
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");
}

4
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: {

4
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()
}));

2
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"
>
<div className="flex flex-col justify-end min-h-full">
<div className="flex flex-col gap-1.5 justify-end min-h-full">
{messages?.map((message, index) => (
<MessageItem
key={message.messageId + index}

230
src/components/PageComponents/Messages/MessageInput.test.tsx

@ -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();
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
setMessageState: mockSetMessageState,
activeChat: 123,
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
});
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');
mockSetMessageState.mockClear();
mockSetActiveChat.mockClear();
mockSetDraft.mockClear();
mockGetDraft.mockClear();
mockClearDraft.mockClear();
mockSendText.mockClear();
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn);
});
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();
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => {
render(<MessageInput {...props} />);
};
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')
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);
expect(screen.getByText('100/100')).toBeInTheDocument();
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
await waitFor(() => {
expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3);
expect(mockSetMessageState).toHaveBeenCalledWith({
type: 'direct',
key: 123,
messageId: undefined,
newState: 'ack',
});
expect(mockClearDraft).toHaveBeenCalledWith(2);
expect(inputElement.value).toBe('');
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
});
});
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);
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(mockSetMessageState).toHaveBeenCalledWith(
'broadcast',
0,
'broadcast',
1234567890,
123,
'ack'
);
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');
});
expect(inputField).toHaveValue('');
expect(screen.getByText('0/100')).toBeInTheDocument();
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
} catch (e) {
console.error(e);
}
});
it('prevents sending empty messages', () => {
render(<MessageInput {...mockProps} />);
const form = screen.getByPlaceholderText('Enter Message')
fireEvent.submit(form);
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);
expect(mockSendText).not.toHaveBeenCalled();
await waitFor(() => {
expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1);
expect(mockSetMessageState).toHaveBeenCalledWith({
type: 'direct',
key: 123,
messageId: 456,
newState: 'failed',
});
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,
},
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');
});
});

11
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 (
<div className="flex gap-2">
<form className="w-full" onSubmit={handleSubmit}>
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-2">
<label className="w-full">
<Input

135
src/components/PageComponents/Messages/MessageItem.tsx

@ -5,13 +5,14 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { MessageState, useDeviceStore } from "@core/stores/deviceStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts";
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 } from "@core/stores/messageStore.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<MessageState, MessageStatus> = {
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 }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
side="top"
align="center"
sideOffset={5}
>
<TooltipContent /* ...props... */ >
{status.displayText}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
@ -51,77 +48,109 @@ 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 (
<StatusTooltip status={status}>
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
<Icon className={iconClass} {...otherProps} color={isFailed ? "currentColor" : undefined} />
</StatusTooltip>
);
};
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 }) => {
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
const _date = new Date(date);
return (<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" })}
const locale = 'en-US'; // TODO: this should be dynamic based on user settings
return (
<div className={cn("flex items-center gap-1 text-xs font-mono", className)}>
<span>
{_date?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true })}
</span>
</div>)
{/* TODO: Conditionally show date for older messages? */}
</div>
);
};
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: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
if (message?.from === null || message?.from === undefined) return null;
for (const device of getDevices()) {
console.log("MessageItem: getDevices", { device });
if (device.nodes.has(message.from)) {
console.log("MessageItem hasNode", { device, message });
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 messageUser = message?.from
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
: null;
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 (
<div className="flex flex-col w-full px-4 justify-start">
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
<div className="flex items-center gap-2 mb-2">
<div className={messageContainerClass}>
<div className={alignmentClass}>
{/* Show only if not consecutive message AND not sent by self */}
{!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1">
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
<div className="flex flex-col">
<span className="font-medium text-slate-900 dark:text-white truncate">
{messageUser?.user?.longName}
<div className="flex items-center gap-1.5 mb-1 px-1">
<Avatar text={shortName} />
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 truncate">
{displayName}
</span>
</div>
</div>
)}
</div>
<div className={cn(
bubbleBaseStyle,
isDeviceUser ? sentBubbleStyle : receivedBubbleStyle
)}>
<div className={cn("flex items-center gap-1.5 mt-1 self-end", timeStatusColor)}>
<TimeDisplay date={message.date} />
<div className="flex place-items-center gap-2 pb-2">
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
<StatusIcon status={messageStatus} />
</div>
<div className={cn(getMessageTextStyles(message.state, isDeviceUser))}>
{message.message || <span className="italic opacity-70">Empty message</span>}
{isDeviceUser && <StatusIcon status={messageStatus} />}
</div>
</div>
</div>
</div>
);
};

32
src/core/dto/PacketToMessageDTO.ts

@ -5,23 +5,33 @@ class PacketToMessageDTO {
channel: Types.ChannelNumber;
to: number;
from: number;
date: string;
date: number; // (timestamp ms)
messageId: number;
state: MessageState;
message: string;
type: MessageType;
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
const payload = data
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;
this.channel = payload.channel
this.to = payload.to;
this.from = payload.from;
this.date = new Date(payload.rxTime).toISOString();
this.messageId = payload.id;
this.state = payload.from !== nodeNum ? "ack" : "waiting";
this.message = payload.data;
this.type = payload.type;
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 {
@ -34,7 +44,7 @@ class PacketToMessageDTO {
state: this.state,
message: this.message,
type: this.type,
} as Message;
};
}
}

290
src/core/stores/messageStore.test.ts

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useMessageStore, Message } from './messageStore';
import { useMessageStore, Message, MessageState, MessageType } from './messageStore.ts';
import { Types } from '@meshtastic/core';
vi.mock('./storage/indexDB.ts', () => ({
zustandIndexDBStorage: {
@ -12,10 +13,10 @@ vi.mock('./storage/indexDB.ts', () => ({
beforeEach(() => {
useMessageStore.setState({
messages: { direct: {}, broadcast: {} },
draft: new Map(),
draft: new Map<Types.Destination, string>(),
nodeNum: 0,
activeChat: 0,
chatType: 'broadcast',
chatType: MessageType.Broadcast,
});
});
@ -25,163 +26,312 @@ describe('useMessageStore', () => {
expect(useMessageStore.getState().getNodeNum()).toBe(42);
});
it('saves and retrieves a direct message', () => {
it('sets activeChat', () => {
useMessageStore.getState().setActiveChat(123);
expect(useMessageStore.getState().activeChat).toBe(123);
});
it('sets chatType', () => {
useMessageStore.getState().setChatType(MessageType.Direct);
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
});
describe('saveMessage', () => {
it('saves a direct message', () => {
const message: Message = {
type: 'direct',
type: MessageType.Direct,
channel: 0,
to: 101,
from: 202,
date: new Date().toISOString(),
date: Date.now(),
messageId: 1,
state: 'waiting',
state: MessageState.Waiting,
message: 'Hello Direct',
};
useMessageStore.getState().saveMessage(message);
expect(useMessageStore.getState().messages.direct[101][1]).toEqual(message);
expect(useMessageStore.getState().messages.direct[101]?.[1]).toEqual(message);
});
it('updates message state', () => {
it('saves a broadcast message', () => {
const message: Message = {
type: 'direct',
type: MessageType.Broadcast,
channel: 5,
to: 0,
from: 303,
date: Date.now(),
messageId: 100,
state: MessageState.Waiting,
message: 'Broadcast Message',
};
useMessageStore.getState().saveMessage(message);
expect(useMessageStore.getState().messages.broadcast[5]?.[100]).toEqual(message);
});
it('ensures date is stored as milliseconds', () => {
const now = Date.now();
const message: Message = {
type: MessageType.Direct,
channel: 0,
to: 101,
from: 202,
date: new Date().toISOString(),
date: now,
messageId: 1,
state: 'waiting',
state: MessageState.Waiting,
message: 'Hello Direct',
};
useMessageStore.getState().saveMessage(message);
expect(useMessageStore.getState().messages.direct[101]?.[1]?.date).toBe(new Date(now).getTime());
});
});
describe('setMessageState', () => {
it('updates the state of an existing direct message', () => {
const message: Message = {
type: MessageType.Direct,
channel: 0,
to: 101,
from: 202,
date: Date.now(),
messageId: 1,
state: MessageState.Waiting,
message: 'Change me',
};
useMessageStore.getState().saveMessage(message);
useMessageStore.getState().setMessageState({ type: 'direct', key: 101, messageId: 1, newState: 'ack' });
expect(useMessageStore.getState().messages.direct[101][1].state).toBe('ack');
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
key: 101,
messageId: 1,
newState: MessageState.Ack,
});
expect(useMessageStore.getState().messages.direct[101]?.[1]?.state).toBe(MessageState.Ack);
});
it('updates the state of an existing broadcast message', () => {
const message: Message = {
type: MessageType.Broadcast,
channel: 5,
to: 0,
from: 303,
date: Date.now(),
messageId: 100,
state: MessageState.Waiting,
message: 'Broadcast Message',
};
useMessageStore.getState().saveMessage(message);
useMessageStore.getState().setMessageState({
type: MessageType.Broadcast,
key: 5,
messageId: 100,
newState: MessageState.Failed,
});
expect(useMessageStore.getState().messages.broadcast[5]?.[100]?.state).toBe(MessageState.Failed);
});
it('does not update if the message is not found and logs a warning', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
key: 999,
messageId: 99,
newState: MessageState.Ack,
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Message not found - type: direct, key: 999, messageId: 99',
);
consoleWarnSpy.mockRestore();
});
});
it('clears all messages', () => {
useMessageStore.getState().saveMessage({
type: 'broadcast',
type: MessageType.Broadcast,
channel: 5,
to: 0,
from: 0,
date: new Date().toISOString(),
from: 303,
date: Date.now(),
messageId: 100,
state: 'waiting',
state: MessageState.Waiting,
message: 'Broadcast Message',
});
useMessageStore.getState().clearMessages();
useMessageStore.getState().saveMessage({
type: MessageType.Direct,
channel: 0,
to: 101,
from: 202,
date: Date.now(),
messageId: 1,
state: MessageState.Waiting,
message: 'Hello Direct',
});
useMessageStore.getState().clearAllMessages();
expect(useMessageStore.getState().messages.direct).toEqual({});
expect(useMessageStore.getState().messages.broadcast).toEqual({});
});
it('retrieves sorted broadcast messages', () => {
const earlier = new Date(Date.now() - 10000).toISOString();
const later = new Date().toISOString();
describe('getMessages', () => {
it('retrieves sorted broadcast messages for a channel', () => {
const now = Date.now();
const earlier = now - 10000;
const later = now;
useMessageStore.getState().saveMessage({
type: 'broadcast',
type: MessageType.Broadcast,
channel: 4,
to: 0,
from: 0,
from: 404,
date: later,
messageId: 2,
state: 'waiting',
state: MessageState.Waiting,
message: 'Second',
});
useMessageStore.getState().saveMessage({
type: 'broadcast',
type: MessageType.Broadcast,
channel: 4,
to: 0,
from: 0,
from: 404,
date: earlier,
messageId: 1,
state: 'waiting',
state: MessageState.Waiting,
message: 'First',
});
const messages = useMessageStore.getState().getMessages('broadcast', { channel: 4 });
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 4 });
expect(messages.map((m) => m.message)).toEqual(['First', 'Second']);
expect(messages[0]?.date).toBe(earlier);
expect(messages[1]?.date).toBe(later);
});
// this test is failing and haven't had a chance to debug it
it.skip('merges and sorts direct messages by date', () => {
const now = new Date();
const earlier = new Date(now.getTime() - 10000).toISOString();
const later = new Date(now.getTime() + 10000).toISOString();
it('returns an empty array for broadcast messages if channel does not exist', () => {
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 99 });
expect(messages).toEqual([]);
});
useMessageStore.getState().saveMessage({
type: 'direct',
it('merges and sorts direct messages by date', () => {
const myNodeNum = 1;
const otherNodeNum = 2;
const now = Date.now();
const earlier = now - 10000;
const later = now + 10000;
const incomingMessage: Message = {
type: MessageType.Direct,
channel: 0,
to: 1, // I am node 1
from: 2, // from node 2
to: myNodeNum,
from: otherNodeNum,
date: earlier,
messageId: 1,
state: 'waiting',
message: 'Incoming',
});
useMessageStore.getState().saveMessage({
type: 'direct',
state: MessageState.Ack,
message: 'Incoming from 2',
};
useMessageStore.getState().saveMessage(incomingMessage);
const outgoingMessage: Message = {
type: MessageType.Direct,
channel: 0,
to: 2, // to node 2
from: 1, // I am node 1
to: otherNodeNum,
from: myNodeNum,
date: later,
messageId: 2,
state: 'waiting',
message: 'Outgoing',
state: MessageState.Waiting,
message: 'Outgoing from 1',
};
useMessageStore.getState().saveMessage(outgoingMessage);
const merged = useMessageStore.getState().getMessages(MessageType.Direct, {
myNodeNum: myNodeNum,
otherNodeNum: otherNodeNum,
});
const merged = useMessageStore.getState().getMessages('direct', {
myNodeNum: 2,
otherNodeNum: 1,
expect(merged.length).toBe(2);
expect(merged.map((m) => m.message)).toEqual(['Incoming from 2', 'Outgoing from 1']);
expect(merged[0]?.date).toBe(earlier);
expect(merged[1]?.date).toBe(later);
});
console.log(merged);
it('returns an empty array for direct messages if no messages exist between nodes', () => {
const myNodeNum = 1;
const otherNodeNum = 2;
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
myNodeNum: myNodeNum,
otherNodeNum: otherNodeNum,
});
expect(messages).toEqual([]);
});
});
expect(merged.map(m => m.message)).toEqual(['Incoming', 'Outgoing']);
describe('draft functionality', () => {
it('sets and gets a draft message', () => {
const key: Types.Destination = 123;
useMessageStore.getState().setDraft(key, 'Draft text');
expect(useMessageStore.getState().getDraft(key)).toBe('Draft text');
});
it('sets and gets a draft', () => {
useMessageStore.getState().setDraft(123, 'Draft text');
expect(useMessageStore.getState().getDraft(123)).toBe('Draft text');
it('gets an empty string if no draft exists for a key', () => {
const key: Types.Destination = 456;
expect(useMessageStore.getState().getDraft(key)).toBe('');
});
it('clears a draft', () => {
useMessageStore.getState().setDraft(123, 'Draft to clear');
useMessageStore.getState().clearDraft(123);
expect(useMessageStore.getState().getDraft(123)).toBe('');
it('clears a draft message', () => {
const key: Types.Destination = 123;
useMessageStore.getState().setDraft(key, 'Draft to clear');
useMessageStore.getState().clearDraft(key);
expect(useMessageStore.getState().getDraft(key)).toBe('');
});
});
describe('clearMessageByMessageId', () => {
it('clears a direct message by messageId', () => {
const message: Message = {
type: 'direct',
type: MessageType.Direct,
channel: 0,
to: 111,
from: 222,
date: new Date().toISOString(),
date: Date.now(),
messageId: 42,
state: 'waiting',
state: MessageState.Waiting,
message: 'To be deleted',
};
useMessageStore.getState().saveMessage(message);
expect(useMessageStore.getState().messages.direct[111][42]).toBeDefined();
expect(useMessageStore.getState().messages.direct[111]?.[42]).toBeDefined();
useMessageStore.getState().clearMessageByMessageId(MessageType.Direct, 42);
useMessageStore.getState().clearMessageByMessageId('direct', 42);
expect(useMessageStore.getState().messages.direct[111]?.[42]).toBeUndefined();
expect(useMessageStore.getState().messages.direct[111]).toBeUndefined();
});
it('clears a broadcast message by messageId', () => {
const message: Message = {
type: 'broadcast',
type: MessageType.Broadcast,
channel: 2,
to: 0,
from: 0,
date: new Date().toISOString(),
from: 333,
date: Date.now(),
messageId: 77,
state: 'waiting',
state: MessageState.Waiting,
message: 'Broadcast to delete',
};
useMessageStore.getState().saveMessage(message);
expect(useMessageStore.getState().messages.broadcast[2][77]).toBeDefined();
expect(useMessageStore.getState().messages.broadcast[2]?.[77]).toBeDefined();
useMessageStore.getState().clearMessageByMessageId(MessageType.Broadcast, 77);
useMessageStore.getState().clearMessageByMessageId('broadcast', 77);
expect(useMessageStore.getState().messages.broadcast[2]?.[77]).toBeUndefined();
expect(useMessageStore.getState().messages.broadcast[2]).toBeUndefined();
});
it('does not throw error if trying to clear a non-existent message', () => {
expect(() => {
useMessageStore.getState().clearMessageByMessageId(MessageType.Direct, 999);
useMessageStore.getState().clearMessageByMessageId(MessageType.Broadcast, 999);
}).not.toThrow();
});
});
});

93
src/core/stores/messageStore.ts

@ -4,25 +4,22 @@ import { produce } from 'immer';
import { Types } from '@meshtastic/core';
import { zustandIndexDBStorage } from "./storage/indexDB.ts";
const MESSAGE_STATES = {
ack: "ack",
waiting: "waiting",
failed: 'failed',
};
export type MessageState = keyof typeof MESSAGE_STATES;
export const ChatTypes = {
DIRECT: "direct",
BROADCAST: "broadcast",
} as const;
export enum MessageState {
Ack = "ack",
Waiting = "waiting",
Failed = "failed",
}
export type MessageType = "broadcast" | "direct";
export enum MessageType {
Direct = "direct",
Broadcast = "broadcast",
}
interface MessageBase {
channel: Types.ChannelNumber;
to: number;
from: number;
date: string;
date: number; // Unix timestamp in milliseconds
messageId: number;
state: MessageState;
message: string;
@ -32,7 +29,7 @@ interface GenericMessage<T extends MessageType> extends MessageBase {
type: T;
}
export type Message = GenericMessage<'direct'> | GenericMessage<'broadcast'>;
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
export interface MessageStore {
messages: {
@ -55,10 +52,10 @@ export interface MessageStore {
messageId: number;
newState?: MessageState;
}) => void;
clearMessages: () => 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: (type: MessageType, messageId: number) => void;
clearDraft: (key: Types.Destination) => void;
@ -73,7 +70,7 @@ export const useMessageStore = create<MessageStore>()(
},
draft: new Map<number, string>(),
activeChat: 0,
chatType: 'broadcast',
chatType: MessageType.Broadcast,
nodeNum: 0,
setNodeNum: (nodeNum) => {
set(produce((state: MessageStore) => {
@ -94,22 +91,32 @@ export const useMessageStore = create<MessageStore>()(
saveMessage: (message) => {
set(produce((state: MessageStore) => {
const group = state.messages[message.type];
const key = message.type === 'direct' ? Number(message.to) : Number(message.channel);
// Direct messages are keyed by the RECIPIENT's node number (`message.to`)
// Broadcast messages are keyed by the channel number (`message.channel`)
const key = message.type === MessageType.Direct ? Number(message.to) : Number(message.channel);
if (!group[key]) {
group[key] = {};
}
group[key][message.messageId] = message;
const messageToSave = { ...message };
group[key][message.messageId] = messageToSave;
}));
},
setMessageState: ({ type, key, messageId, newState = 'ack' }) => {
const group = get().messages[type];
const messageMap = group[key];
if (!messageMap || !messageMap[messageId]) return;
set(produce((state: MessageStore) => {
state.messages[type][key][messageId].state = newState;
}));
setMessageState: ({
type,
key,
messageId,
newState = MessageState.Ack,
}) => {
set(
produce((state: MessageStore) => {
const message = state.messages[type]?.[key]?.[messageId];
if (message) {
message.state = newState;
} else {
console.warn(`Message not found - type: ${type}, key: ${key}, messageId: ${messageId}`);
}
}),
);
},
clearMessages: () => {
set(produce((state: MessageStore) => {
@ -120,24 +127,26 @@ export const useMessageStore = create<MessageStore>()(
getMessages: (type, options) => {
const state = get();
if (type === 'broadcast' && options.channel !== undefined) {
if (type === MessageType.Broadcast && options.channel !== undefined) {
const messageMap = state.messages.broadcast[options.channel] ?? {};
return Object.values(messageMap).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
return Object.values(messageMap).sort((a, b) => a.date - b.date);
}
if (type === 'direct' && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
const receivedMap = state.messages.direct[options.otherNodeNum] ?? {};
const sentMap = state.messages.direct[options.myNodeNum] ?? {};
// Pull messages where I am the sender and otherNode is the receiver
const sentMessages = Object.values(sentMap).filter(msg => msg.to === options.otherNodeNum);
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
// Messages TO the other node (sent by me) are keyed under their nodeNum
const messagesToOtherNodeMap = state.messages.direct[options.otherNodeNum] ?? {};
const sentByMe = Object.values(messagesToOtherNodeMap);
// Pull messages received from otherNode
const receivedMessages = Object.values(receivedMap);
// Messages TO me (potentially from the other node) are keyed under my nodeNum
const messagesToMeMap = state.messages.direct[options.myNodeNum] ?? {};
// Filter messages TO me to find the ones FROM the specific other node
const sentByOtherNode = Object.values(messagesToMeMap).filter(
(msg) => msg.from === options.otherNodeNum
);
// Merge and sort chronologically
return [...receivedMessages, ...sentMessages].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
return [...sentByMe, ...sentByOtherNode].sort(
(a, b) => a.date - b.date
);
}
@ -170,6 +179,12 @@ export const useMessageStore = create<MessageStore>()(
state.draft.delete(key);
}));
},
clearAllMessages: () => {
set(produce((state: MessageStore) => {
state.messages.direct = {};
state.messages.broadcast = {};
}));
}
}),
{
name: 'meshtastic-message-store',

24
src/pages/Messages.tsx

@ -13,7 +13,7 @@ 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 { ChatTypes, useMessageStore } from "@core/stores/messageStore.ts";
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
export const MessagesPage = () => {
const { channels, nodes, hardware, hasNodeError } = useDevice();
@ -36,8 +36,8 @@ export const MessagesPage = () => {
const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
const isDirect = chatType === ChatTypes.DIRECT;
const isBroadcast = chatType === ChatTypes.BROADCAST;
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const currentChat = { type: chatType, id: activeChat };
@ -54,7 +54,7 @@ export const MessagesPage = () => {
? "Primary"
: `Ch ${channel.index}`}
onClick={() => {
setChatType("broadcast");
setChatType(MessageType.Broadcast);
setActiveChat(channel.index);
}}
element={<HashIcon size={16} className="mr-2" />}
@ -79,7 +79,7 @@ export const MessagesPage = () => {
`!${numberToHexUnpadded(otherNode.num)}`}
active={activeChat === otherNode.num && chatType === "direct"}
onClick={() => {
setChatType("direct");
setChatType(MessageType.Direct);
setActiveChat(otherNode.num);
}}
element={
@ -98,13 +98,13 @@ export const MessagesPage = () => {
<div className="flex flex-col w-full h-full container mx-auto">
<PageLayout
className="flex flex-col h-full"
label={`Messages: ${chatType === "broadcast" && currentChannel
label={`Messages: ${MessageType.Broadcast && currentChannel
? getChannelName(currentChannel)
: chatType === "direct" && nodes.get(activeChat)
: chatType === MessageType.Direct && nodes.get(activeChat)
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
: "Loading..."
}`}
actions={chatType === "direct"
actions={chatType === MessageType.Direct
? [
{
icon: nodes.get(activeChat)?.user?.publicKey.length
@ -131,7 +131,7 @@ export const MessagesPage = () => {
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<ChannelChat
messages={getMessages('broadcast', {
messages={getMessages(MessageType.Broadcast, {
myNodeNum: getNodeNum(),
channel: currentChannel?.index
})}
@ -144,7 +144,7 @@ export const MessagesPage = () => {
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<ChannelChat
messages={getMessages('direct', { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
/>
</div>
</div>
@ -153,8 +153,8 @@ export const MessagesPage = () => {
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput
to={currentChat.type === ChatTypes.DIRECT ? activeChat : ChatTypes.BROADCAST}
channel={currentChat.type === ChatTypes.DIRECT ? Types.ChannelNumber.Primary : currentChat.id}
to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast}
channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id}
maxBytes={200}
/>
</div>

Loading…
Cancel
Save