Browse Source

Merge pull request #536 from danditomaso/add-message-persistance

Add message persistence using IndexedDB
pull/560/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
38b8695441
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      deno.lock
  2. 1
      package.json
  3. 4
      src/components/CommandPalette/index.tsx
  4. 53
      src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx
  5. 63
      src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx
  6. 8
      src/components/Dialog/DialogManager.tsx
  7. 16
      src/components/Dialog/NodeOptionsDialog.tsx
  8. 4
      src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
  9. 4
      src/components/PageComponents/Config/Device/Device.test.tsx
  10. 4
      src/components/PageComponents/Connect/BLE.tsx
  11. 4
      src/components/PageComponents/Connect/HTTP.tsx
  12. 4
      src/components/PageComponents/Connect/Serial.tsx
  13. 18
      src/components/PageComponents/Messages/ChannelChat.tsx
  14. 175
      src/components/PageComponents/Messages/Message.tsx
  15. 230
      src/components/PageComponents/Messages/MessageInput.test.tsx
  16. 105
      src/components/PageComponents/Messages/MessageInput.tsx
  17. 130
      src/components/PageComponents/Messages/MessageItem.tsx
  18. 51
      src/core/dto/PacketToMessageDTO.ts
  19. 16
      src/core/stores/appStore.ts
  20. 107
      src/core/stores/deviceStore.ts
  21. 372
      src/core/stores/messageStore.test.ts
  22. 234
      src/core/stores/messageStore.ts
  23. 14
      src/core/stores/storage/indexDB.ts
  24. 15
      src/core/subscriptions.ts
  25. 70
      src/pages/Messages.tsx
  26. 8
      src/tests/setupTests.ts
  27. 2
      vitest.config.ts

5
deno.lock

@ -45,6 +45,7 @@
"npm:crypto-random-string@5": "5.0.0", "npm:crypto-random-string@5": "5.0.0",
"npm:gzipper@^8.2.1": "8.2.1", "npm:gzipper@^8.2.1": "8.2.1",
"npm:happy-dom@^17.4.4": "17.4.4", "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:immer@^10.1.1": "10.1.1",
"npm:js-cookie@^3.0.5": "3.0.5", "npm:js-cookie@^3.0.5": "3.0.5",
"npm:[email protected]": "[email protected]", "npm:[email protected]": "[email protected]",
@ -4492,6 +4493,9 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg=="
}, },
"[email protected]": {
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="
}, },
@ -6491,6 +6495,7 @@
"npm:crypto-random-string@5", "npm:crypto-random-string@5",
"npm:gzipper@^8.2.1", "npm:gzipper@^8.2.1",
"npm:happy-dom@^17.4.4", "npm:happy-dom@^17.4.4",
"npm:idb-keyval@^6.2.1",
"npm:immer@^10.1.1", "npm:immer@^10.1.1",
"npm:js-cookie@^3.0.5", "npm:js-cookie@^3.0.5",
"npm:[email protected]", "npm:[email protected]",

1
package.json

@ -62,6 +62,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"crypto-random-string": "^5.0.0", "crypto-random-string": "^5.0.0",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.486.0", "lucide-react": "^0.486.0",

4
src/components/CommandPalette/index.tsx

@ -219,10 +219,10 @@ export const CommandPalette = () => {
}, },
}, },
{ {
label: "[WIP] Clear Messages", label: "Clear All Stored Message",
icon: EraserIcon, icon: EraserIcon,
action() { action() {
alert("This feature is not implemented"); setDialogOpen("clearMessages", true);
}, },
}, },
], ],

53
src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx

@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useMessageStore } from "@core/stores/messageStore.ts";
import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx";
vi.mock('@core/stores/messageStore.ts', () => ({
useMessageStore: vi.fn(() => ({
clearAllMessages: vi.fn(),
})),
}));
describe('ClearMessagesDialog', () => {
const mockOnOpenChange = vi.fn();
const mockClearAllMessages = vi.fn();
beforeEach(() => {
vi.mocked(useMessageStore).mockReturnValue({ clearAllMessages: mockClearAllMessages });
mockOnOpenChange.mockClear();
mockClearAllMessages.mockClear();
});
it('renders the dialog when open is true', () => {
render(<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);
});
});

63
src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx

@ -0,0 +1,63 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore.ts";
export interface ClearMessagesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ClearMessagesDialog = ({
open,
onOpenChange,
}: ClearMessagesDialogProps) => {
const { clearAllMessages } = useMessageStore();
const handleCloseDialog = () => {
onOpenChange(false);
};
return (
<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>
);
};

8
src/components/Dialog/DialogManager.tsx

@ -10,6 +10,8 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx"; import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx";
export const DialogManager = () => { export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
@ -84,6 +86,12 @@ export const DialogManager = () => {
setDialogOpen("rebootOTA", open); setDialogOpen("rebootOTA", open);
}} }}
/> />
<ClearMessagesDialog
open={dialog.clearMessages}
onOpenChange={(open) => {
setDialogOpen("clearMessages", open);
}}
/>
</> </>
); );
}; };

16
src/components/Dialog/NodeOptionsDialog.tsx

@ -13,6 +13,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import { Button } from "../UI/Button.tsx"; import { Button } from "../UI/Button.tsx";
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
export interface NodeOptionsDialogProps { export interface NodeOptionsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined; node: Protobuf.Mesh.NodeInfo | undefined;
@ -29,23 +30,23 @@ export const NodeOptionsDialog = ({
const { const {
setNodeNumToBeRemoved, setNodeNumToBeRemoved,
setNodeNumDetails, setNodeNumDetails,
setChatType,
setActiveChat,
} = useAppStore(); } = useAppStore();
const { setChatType, setActiveChat } = useMessageStore();
if (!node) return null;
const longName = node?.user?.longName ?? const longName = node?.user?.longName ??
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown"); (node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
const shortName = node?.user?.shortName ?? const shortName = node?.user?.shortName ??
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK"); (node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
function handleDirectMessage() { function handleDirectMessage() {
if (!node) return; setChatType(MessageType.Direct);
setChatType("direct");
setActiveChat(node.num); setActiveChat(node.num);
setActivePage("messages"); setActivePage("messages");
} }
function handleRequestPosition() { function handleRequestPosition() {
if (!node) return;
toast({ toast({
title: "Requesting position, please wait...", title: "Requesting position, please wait...",
}); });
@ -58,7 +59,6 @@ export const NodeOptionsDialog = ({
} }
function handleTraceroute() { function handleTraceroute() {
if (!node) return;
toast({ toast({
title: "Sending Traceroute, please wait...", title: "Sending Traceroute, please wait...",
}); });
@ -92,7 +92,7 @@ export const NodeOptionsDialog = ({
key="remove" key="remove"
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setNodeNumToBeRemoved(node.num); setNodeNumToBeRemoved(node?.num);
setDialogOpen("nodeRemoval", true); setDialogOpen("nodeRemoval", true);
}} }}
> >
@ -103,7 +103,7 @@ export const NodeOptionsDialog = ({
<div> <div>
<Button <Button
onClick={() => { onClick={() => {
setNodeNumDetails(node.num); setNodeNumDetails(node?.num);
setDialogOpen("nodeDetails", true); setDialogOpen("nodeDetails", true);
}} }}
> >

4
src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog"; import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { eventBus } from "@core/utils/eventBus"; import { eventBus } from "@core/utils/eventBus.ts";
vi.mock('@core/utils/eventBus', () => ({ vi.mock('@core/utils/eventBus', () => ({
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 { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
vi.mock('@core/stores/deviceStore', () => ({ vi.mock('@core/stores/deviceStore.ts', () => ({
useDevice: vi.fn() useDevice: vi.fn()
})); }));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({ vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({
useUnsafeRolesDialog: vi.fn() useUnsafeRolesDialog: vi.fn()
})); }));

4
src/components/PageComponents/Connect/BLE.tsx

@ -7,10 +7,12 @@ import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts"; import { randId } from "@core/utils/randId.ts";
import { BleConnection, ServiceUuid } from "@meshtastic/js"; import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore.ts";
export const BLE = ({ closeDialog }: TabElementProps) => { export const BLE = ({ closeDialog }: TabElementProps) => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]); const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
const messageStore = useMessageStore()
const { setSelectedDevice } = useAppStore(); const { setSelectedDevice } = useAppStore();
const updateBleDeviceList = useCallback(async (): Promise<void> => { const updateBleDeviceList = useCallback(async (): Promise<void> => {
@ -30,7 +32,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
device: bleDevice, device: bleDevice,
}); });
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection); subscribeAll(device, connection, messageStore);
closeDialog(); closeDialog();
}; };

4
src/components/PageComponents/Connect/HTTP.tsx

@ -13,6 +13,7 @@ import { TransportHTTP } from "@meshtastic/transport-http";
import { useState } from "react"; import { useState } from "react";
import { useForm, useController } from "react-hook-form"; import { useForm, useController } from "react-hook-form";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore.ts";
interface FormData { interface FormData {
ip: string; ip: string;
@ -23,6 +24,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
const isURLHTTPS = location.protocol === "https:"; const isURLHTTPS = location.protocol === "https:";
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
const messageStore = useMessageStore();
const { setSelectedDevice } = useAppStore(); const { setSelectedDevice } = useAppStore();
const { control, handleSubmit, register } = useForm<FormData>({ const { control, handleSubmit, register } = useForm<FormData>({
@ -55,7 +57,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
connection.configure(); connection.configure();
setSelectedDevice(id); setSelectedDevice(id);
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection); subscribeAll(device, connection, messageStore);
closeDialog(); closeDialog();
} catch (error) { } catch (error) {
console.error("Connection error:", error); console.error("Connection error:", error);

4
src/components/PageComponents/Connect/Serial.tsx

@ -8,10 +8,12 @@ import { randId } from "@core/utils/randId.ts";
import { MeshDevice } from "@meshtastic/core"; import { MeshDevice } from "@meshtastic/core";
import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { TransportWebSerial } from "@meshtastic/transport-web-serial";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore.ts";
export const Serial = ({ closeDialog }: TabElementProps) => { export const Serial = ({ closeDialog }: TabElementProps) => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]); const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
const { addDevice } = useDeviceStore(); const { addDevice } = useDeviceStore();
const messageStore = useMessageStore()
const { setSelectedDevice } = useAppStore(); const { setSelectedDevice } = useAppStore();
const updateSerialPortList = useCallback(async () => { const updateSerialPortList = useCallback(async () => {
@ -36,7 +38,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
const connection = new MeshDevice(transport, id); const connection = new MeshDevice(transport, id);
connection.configure(); connection.configure();
device.addConnection(connection); device.addConnection(connection);
subscribeAll(device, connection); subscribeAll(device, connection, messageStore);
closeDialog(); closeDialog();
}; };

18
src/components/PageComponents/Messages/ChannelChat.tsx

@ -1,10 +1,10 @@
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts"; import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import { Message } from "@components/PageComponents/Messages/Message.tsx"; import type { Message as Message } from "@core/stores/messageStore.ts";
import { InboxIcon } from "lucide-react"; import { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
export interface ChannelChatProps { export interface ChannelChatProps {
messages?: MessageWithState[]; messages?: Message[];
} }
const EmptyState = () => ( const EmptyState = () => (
@ -17,8 +17,6 @@ const EmptyState = () => (
export const ChannelChat = ({ export const ChannelChat = ({
messages = [], messages = [],
}: ChannelChatProps) => { }: ChannelChatProps) => {
const { nodes } = useDevice();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -57,14 +55,14 @@ export const ChannelChat = ({
ref={scrollContainerRef} ref={scrollContainerRef}
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44" 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) => ( {messages?.map((message, index) => (
<Message <MessageItem
key={message.id} key={message.messageId + index}
message={message} message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={ lastMsgSameUser={
index > 0 && messages[index - 1].from === message.from index > 0 &&
messages[index - 1].from === message.from
} }
/> />
))} ))}

175
src/components/PageComponents/Messages/Message.tsx

@ -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>
);
});

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 { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import { vi, describe, it, expect, beforeEach } from 'vitest';
import { MessageInput } from './MessageInput.tsx';
vi.mock("@core/stores/deviceStore.ts", () => ({ import { useDevice } from '@core/stores/deviceStore.ts';
useDevice: vi.fn(), 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", () => ({ vi.mock('@components/UI/Input.tsx', () => ({
debounce: (fn: () => void) => fn, 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", () => ({ vi.mock('@core/stores/deviceStore.ts', () => ({
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button> useDevice: vi.fn(),
})); }));
vi.mock("@components/UI/Input.tsx", () => ({ vi.mock('@core/stores/messageStore.ts', () => ({
Input: (props: any) => <input {...props} /> useMessageStore: vi.fn(),
MessageState: {
Ack: 'ack',
Waiting: 'waiting',
Failed: 'failed',
},
MessageType: {
Direct: 'direct',
Broadcast: 'broadcast',
},
})); }));
vi.mock("lucide-react", () => ({ vi.mock('@core/utils/debounce.ts', () => ({
SendIcon: () => <div data-testid="send-icon">Send</div> debounce: vi.fn((fn) => fn),
})); }));
// TODO: getting an error with this test vi.mock('lucide-react', () => ({
describe('MessageInput Component', () => { SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
const mockProps = { }));
to: "broadcast" as const,
channel: 0 as const,
maxBytes: 100,
};
const mockSetMessageDraft = vi.fn(); describe('MessageInput', () => {
const mockSetMessageState = vi.fn(); 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(() => { beforeEach(() => {
vi.clearAllMocks(); (useDevice as ReturnType<typeof vi.fn>).mockReturnValue({
(useDevice as Mock).mockReturnValue({
connection: { connection: {
sendText: mockSendText, 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', () => { mockSetMessageState.mockClear();
render(<MessageInput {...mockProps} />); mockSetActiveChat.mockClear();
mockSetDraft.mockClear();
const inputField = screen.getByPlaceholderText('Enter Message'); mockGetDraft.mockClear();
fireEvent.change(inputField, { target: { value: 'Hello' } }) mockClearDraft.mockClear();
mockSendText.mockClear();
expect(screen.getByText('5/100')).toBeInTheDocument(); (debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn);
expect(inputField).toHaveValue('Hello');
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
}); });
it.skip('does not allow input exceeding max bytes', () => { const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => {
render(<MessageInput {...mockProps} maxBytes={5} />); render(<MessageInput {...props} />);
};
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') 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(); await waitFor(() => {
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'); 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 () => { it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => {
try { renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 });
render(<MessageInput {...mockProps} />); const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: 'Broadcast message' } });
const inputField = screen.getByPlaceholderText('Enter Message'); const formElement = screen.getByRole('form');
const submitButton = screen.getByText('Send'); fireEvent.submit(formElement);
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(() => { await waitFor(() => {
expect(mockSetMessageState).toHaveBeenCalledWith( expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5);
'broadcast', expect(mockSetMessageState).toHaveBeenCalledWith({
0, type: 'broadcast',
'broadcast', key: 123,
1234567890, messageId: undefined,
123, newState: 'ack',
'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') it('updates state to Failed if sendText throws an error', async () => {
fireEvent.submit(form); 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',
}); });
expect(mockClearDraft).toHaveBeenCalledWith(3);
it('initializes with existing message draft', () => { expect(inputElement.value).toBe('');
(useDevice as Mock).mockReturnValue({ expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
connection: {
sendText: mockSendText,
},
setMessageState: mockSetMessageState,
messageDraft: "Existing draft",
setMessageDraft: mockSetMessageDraft,
isQueueingMessages: false,
queueStatus: { free: 10 },
hardware: {
myNodeNum: 1234567890,
},
}); });
render(<MessageInput {...mockProps} />);
const inputField = screen.getByRole('textbox');
expect(inputField).toHaveValue('Existing draft');
}); });
}); });

105
src/components/PageComponents/Messages/MessageInput.tsx

@ -1,10 +1,11 @@
import { debounce } from "@core/utils/debounce.ts";
import { Button } from "@components/UI/Button.tsx"; import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Types } from "@meshtastic/core"; import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react"; import { SendIcon } from "lucide-react";
import { startTransition, useCallback, useMemo, useState } from "react"; import { startTransition, useCallback, useMemo, useState } from "react";
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
import { debounce } from "@core/utils/debounce.ts";
export interface MessageInputProps { export interface MessageInputProps {
to: Types.Destination; to: Types.Destination;
@ -17,57 +18,41 @@ export const MessageInput = ({
channel, channel,
maxBytes, maxBytes,
}: MessageInputProps) => { }: MessageInputProps) => {
const { const { connection } = useDevice();
connection, const { setMessageState, activeChat, setDraft, getDraft, clearDraft } = useMessageStore();
setMessageState,
messageDraft, const [localDraft, setLocalDraft] = useState(getDraft(to));
setMessageDraft,
isQueueingMessages,
queueStatus,
hardware,
} = useDevice();
const myNodeNum = hardware.myNodeNum;
const [localDraft, setLocalDraft] = useState(messageDraft);
const [messageBytes, setMessageBytes] = useState(0); const [messageBytes, setMessageBytes] = useState(0);
const debouncedSetMessageDraft = useMemo( const debouncedSetMessageDraft = useMemo(
() => debounce(setMessageDraft, 300), () => debounce((value: string) => setDraft(to, value), 300),
[setMessageDraft], [setDraft, to]
); );
// sends the message to the selected destination const calculateBytes = (text: string) => new Blob([text]).size;
const sendText = useCallback(
async (message: string) => {
await connection const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct;
?.sendText(message, to, true, channel)
.then((id: number) => const sendText = useCallback(async (message: string) => {
setMessageState( try {
to === "broadcast" ? "broadcast" : "direct", const messageId = await connection?.sendText(message, to, true, channel);
channel, if (messageId !== undefined) {
to as number, setMessageState({ type: chatType, key: activeChat, messageId, newState: MessageState.Ack });
myNodeNum, }
id, // deno-lint-ignore no-explicit-any
"ack", } catch (e: any) {
) setMessageState({
) type: chatType,
.catch((e: Types.PacketError) => key: activeChat,
setMessageState( messageId: e?.id,
to === "broadcast" ? "broadcast" : "direct", newState: MessageState.Failed,
channel, });
to as number, }
myNodeNum, }, [channel, connection, setMessageState, to, activeChat, chatType]);
e.id,
e.error,
)
);
},
[channel, connection, myNodeNum, setMessageState, to, queueStatus],
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
const byteLength = new Blob([newValue]).size; const byteLength = calculateBytes(newValue);
if (byteLength <= maxBytes) { if (byteLength <= maxBytes) {
setLocalDraft(newValue); setLocalDraft(newValue);
@ -76,26 +61,22 @@ export const MessageInput = ({
} }
}; };
return ( const handleSubmit = (e: React.FormEvent) => {
<div className="flex gap-2"> e.preventDefault();
<form if (!localDraft.trim()) return;
className="w-full"
action={(formData: FormData) => {
// prevent user from sending blank/empty message
if (localDraft === "") return;
const message = formData.get("messageInput") as string;
startTransition(() => { startTransition(() => {
if (!isQueueingMessages) { sendText(localDraft.trim());
sendText(message);
setLocalDraft(""); setLocalDraft("");
setMessageDraft(""); clearDraft(to);
setMessageBytes(0); setMessageBytes(0);
}
}); });
}} };
>
<div className="flex grow gap-2 "> return (
<div className="flex gap-2">
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-2">
<label className="w-full"> <label className="w-full">
<Input <Input
autoFocus autoFocus
@ -106,11 +87,15 @@ export const MessageInput = ({
onChange={handleInputChange} onChange={handleInputChange}
/> />
</label> </label>
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end"> <label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
{messageBytes}/{maxBytes} {messageBytes}/{maxBytes}
</label> </label>
<Button type="submit" className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white"> <Button
type="submit"
className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white"
>
<SendIcon size={16} /> <SendIcon size={16} />
</Button> </Button>
</div> </div>

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

@ -11,7 +11,8 @@ import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { Message, MessageState } from "@core/services/types.ts"; import { Message, MessageState, useMessageStore } from "@core/stores/messageStore.ts";
import { Protobuf } from "@meshtastic/js";
interface MessageProps { interface MessageProps {
lastMsgSameUser: boolean; lastMsgSameUser: boolean;
@ -25,24 +26,20 @@ interface MessageStatus {
} }
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = { const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 }, [MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2 },
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis }, [MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis },
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle }, [MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle },
}; };
const getMessageStatus = (state: MessageState): MessageStatus => 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 }) => ( const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent <TooltipContent /* ...props... */ >
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}
>
{status.displayText} {status.displayText}
<TooltipArrow className="fill-slate-800" /> <TooltipArrow className="fill-slate-800" />
</TooltipContent> </TooltipContent>
@ -51,76 +48,105 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children:
); );
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => { const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
const isFailed = status.state === "failed"; const isFailed = status.state === MessageState.Failed;
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className); const iconClass = cn("w-4 h-4 shrink-0", className);
const Icon = status.icon; const Icon = status.icon;
return ( return (
<StatusTooltip status={status}> <StatusTooltip status={status}>
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} /> <Icon className={iconClass} {...otherProps} color={isFailed ? "currentColor" : undefined} />
</StatusTooltip> </StatusTooltip>
); );
}; };
const getMessageTextStyles = (status: MessageStatus) => { const getMessageTextStyles = (status: MessageState, isDeviceUser: boolean) => {
const isAcknowledged = status.state === "ack"; const isFailed = status === MessageState.Failed;
const isFailed = status.state === "failed";
return cn( return cn(
"break-words overflow-hidden", "break-words overflow-hidden whitespace-pre-wrap flex items-center gap-1.5",
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400", isFailed && (isDeviceUser ? "text-red-500" : "text-red-600 dark:text-red-500")
isFailed && "text-red-500 dark:text-red-500",
); );
}; };
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
<div className={cn("flex items-center gap-2 shrink-0", className)}> const TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span> const _date = new Date(date);
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono"> const locale = 'en-US'; // TODO: this should be dynamic based on user settings
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} 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> </span>
{/* TODO: Conditionally show date for older messages? */}
</div> </div>
); );
};
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => { export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
const myNodeNum = useMessageStore((state) => state.nodeNum);
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const isDeviceUser = useMemo( const isDeviceUser = message.from === myNodeNum;
() =>
getDevices() const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) if (message?.from === null || message?.from === undefined) return null;
.includes(message.from), for (const device of getDevices()) {
[getDevices, message.from], if (device.nodes.has(message.from)) {
); return device.nodes.get(message.from) ?? null;
}
}
return null;
}, [getDevices, message.from]);
const messageUser = message?.from const fallbackName = `${message.from}`;
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from) const longName = messageUser?.user?.longName;
: null; const shortName = messageUser?.user?.shortName ?? fallbackName.slice(0, 2).toUpperCase();
const displayName = isDeviceUser ? "You" : (longName || fallbackName);
const messageContainerClass = cn(
"flex flex-col w-full px-4 justify-start",
!lastMsgSameUser ? "pt-3" : "pt-0.5"
);
const alignmentClass = cn(
"flex flex-col flex-wrap w-full",
isDeviceUser ? "items-end" : "items-start"
);
const bubbleBaseStyle = "flex flex-col max-w-[75%] rounded-lg px-3 py-1.5 text-sm shadow-md";
const sentBubbleStyle = "bg-gradient-to-br from-blue-600 to-blue-700 dark:from-blue-500 dark:to-blue-600 text-white";
const receivedBubbleStyle = "bg-slate-200 dark:bg-slate-500 text-slate-900 dark:text-white";
const timeStatusColor = isDeviceUser ? "text-blue-100 dark:text-blue-200" : "text-slate-500 dark:text-slate-300";
const messageStatus = getMessageStatus(message.state); const messageStatus = getMessageStatus(message.state);
const messageTextClass = getMessageTextStyles(messageStatus);
return ( return (
<div className="flex flex-col w-full px-4 justify-start"> <div className={messageContainerClass}>
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}> <div className={alignmentClass}>
<div className="flex items-center gap-2 mb-2">
{/* Show only if not consecutive message AND not sent by self */}
{!lastMsgSameUser && ( {!lastMsgSameUser && (
<div className="flex place-items-center gap-2 mb-1"> <div className="flex items-center gap-1.5 mb-1 px-1">
<Avatar text={messageUser?.user?.shortName ?? "UNK"} /> <Avatar text={shortName} />
<div className="flex flex-col"> <span className="text-xs font-medium text-slate-600 dark:text-slate-400 truncate">
<span className="font-medium text-slate-900 dark:text-white truncate"> {displayName}
{messageUser?.user?.longName}
</span> </span>
</div> </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} /> <TimeDisplay date={message.date} />
<div className="flex place-items-center gap-2 pb-2"> </div>
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
<StatusIcon status={messageStatus} /> <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> </div>
</div> </div>
); );
}; };

51
src/core/dto/PacketToMessageDTO.ts

@ -0,0 +1,51 @@
import type { Types } from "@meshtastic/js";
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts";
class PacketToMessageDTO {
channel: Types.ChannelNumber;
to: number;
from: number;
date: number; // (timestamp ms)
messageId: number;
state: MessageState;
message: string;
type: MessageType;
constructor(data: Types.PacketMetadata<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;

16
src/core/stores/appStore.ts

@ -1,4 +1,3 @@
import { Types } from "@meshtastic/core";
import { produce } from "immer"; import { produce } from "immer";
import { create } from "zustand"; import { create } from "zustand";
@ -25,14 +24,11 @@ interface AppState {
id: number; id: number;
num: number; num: number;
}[]; }[];
rasterSources: RasterSource[]; rasterSources: RasterSource[];
commandPaletteOpen: boolean; commandPaletteOpen: boolean;
nodeNumToBeRemoved: number; nodeNumToBeRemoved: number;
connectDialogOpen: boolean; connectDialogOpen: boolean;
nodeNumDetails: number; nodeNumDetails: number;
activeChat: number;
chatType: "broadcast" | "direct";
errors: ErrorState[]; errors: ErrorState[];
setRasterSources: (sources: RasterSource[]) => void; setRasterSources: (sources: RasterSource[]) => void;
@ -45,8 +41,6 @@ interface AppState {
setNodeNumToBeRemoved: (nodeNum: number) => void; setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void; setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void; setNodeNumDetails: (nodeNum: number) => void;
setActiveChat: (chat: number) => void;
setChatType: (type: "broadcast" | "direct") => void;
// Error management // Error management
hasErrors: () => boolean; hasErrors: () => boolean;
@ -67,8 +61,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
connectDialogOpen: false, connectDialogOpen: false,
nodeNumToBeRemoved: 0, nodeNumToBeRemoved: 0,
nodeNumDetails: 0, nodeNumDetails: 0,
activeChat: Types.ChannelNumber.Primary,
chatType: "broadcast",
errors: [], errors: [],
setRasterSources: (sources: RasterSource[]) => { setRasterSources: (sources: RasterSource[]) => {
@ -127,14 +119,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
set(() => ({ set(() => ({
nodeNumDetails: nodeNum, nodeNumDetails: nodeNum,
})), })),
setActiveChat: (chat) =>
set(() => ({
activeChat: chat,
})),
setChatType: (type) =>
set(() => ({
chatType: type,
})),
hasErrors: () => { hasErrors: () => {
const state = get(); const state = get();
return state.errors.length > 0; return state.errors.length > 0;

107
src/core/stores/deviceStore.ts

@ -10,7 +10,7 @@ export interface MessageWithState extends Types.PacketMetadata<string> {
state: MessageState; state: MessageState;
} }
export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error; export type MessageState = "ack" | "waiting" | 'failed';
export interface ProcessPacketParams { export interface ProcessPacketParams {
from: number; from: number;
@ -29,11 +29,8 @@ export type DialogVariant =
| "pkiBackup" | "pkiBackup"
| "nodeDetails" | "nodeDetails"
| "unsafeRoles" | "unsafeRoles"
| "refreshKeys"; | "refreshKeys"
| "clearMessages";
type QueueStatus = {
res: number, free: number, maxlen: number
}
type NodeError = { type NodeError = {
node: number; node: number;
@ -51,10 +48,6 @@ export interface Device {
hardware: Protobuf.Mesh.MyNodeInfo; hardware: Protobuf.Mesh.MyNodeInfo;
nodes: Map<number, Protobuf.Mesh.NodeInfo>; nodes: Map<number, Protobuf.Mesh.NodeInfo>;
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>; metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
messages: {
direct: Map<number, MessageWithState[]>;
broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
};
traceroutes: Map< traceroutes: Map<
number, number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[] Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
@ -67,8 +60,6 @@ export interface Device {
// currentMetrics: Protobuf.DeviceMetrics; // currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean; pendingSettingsChanges: boolean;
messageDraft: string; messageDraft: string;
queueStatus: QueueStatus,
isQueueingMessages: boolean,
dialog: { dialog: {
import: boolean; import: boolean;
QR: boolean; QR: boolean;
@ -81,6 +72,7 @@ export interface Device {
nodeDetails: boolean; nodeDetails: boolean;
unsafeRoles: boolean; unsafeRoles: boolean;
refreshKeys: boolean; refreshKeys: boolean;
clearMessages: boolean;
}; };
@ -100,25 +92,15 @@ export interface Device {
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void; addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void; addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: MeshDevice) => void; addConnection: (connection: MeshDevice) => void;
addMessage: (message: MessageWithState) => void;
addTraceRoute: ( addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>, traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
) => void; ) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void; addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
removeNode: (nodeNum: number) => void; removeNode: (nodeNum: number) => void;
setMessageState: (
type: "direct" | "broadcast",
channelIndex: Types.ChannelNumber,
to: number,
from: number,
messageId: number,
state: MessageState,
) => void;
setDialogOpen: (dialog: DialogVariant, open: boolean) => void; setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
getDialogOpen: (dialog: DialogVariant) => boolean; getDialogOpen: (dialog: DialogVariant) => boolean;
processPacket: (data: ProcessPacketParams) => void; processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void; setMessageDraft: (message: string) => void;
setQueueStatus: (status: QueueStatus) => void;
setNodeError: (nodeNum: number, error: string) => void; setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void; clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined; getNodeError: (nodeNum: number) => NodeError | undefined;
@ -153,19 +135,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
hardware: create(Protobuf.Mesh.MyNodeInfoSchema), hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
nodes: new Map(), nodes: new Map(),
metadata: new Map(), metadata: new Map(),
messages: {
direct: new Map(),
broadcast: new Map(),
},
traceroutes: new Map(), traceroutes: new Map(),
connection: undefined, connection: undefined,
activePage: "messages", activePage: "messages",
activeNode: 0, activeNode: 0,
waypoints: [], waypoints: [],
queueStatus: {
res: 0, free: 0, maxlen: 0
},
isQueueingMessages: false,
dialog: { dialog: {
import: false, import: false,
QR: false, QR: false,
@ -510,31 +484,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}), }),
); );
}, },
addMessage: (message) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
const messageGroup = device.messages[message.type];
const messageIndex = message.type === "direct"
? message.from === device.hardware.myNodeNum
? message.to
: message.from
: message.channel;
const messages = messageGroup.get(messageIndex);
if (messages) {
messages.push(message);
messageGroup.set(messageIndex, messages);
} else {
messageGroup.set(messageIndex, [message]);
}
}),
);
},
addMetadata: (from, metadata) => { addMetadata: (from, metadata) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@ -575,43 +524,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}), }),
); );
}, },
setMessageState: (
type: "direct" | "broadcast",
channelIndex: Types.ChannelNumber,
to: number,
from: number,
messageId: number,
state: MessageState,
) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
const messageGroup = device.messages[type];
const messageIndex = type === "direct"
? from === device.hardware.myNodeNum ? to : from
: channelIndex;
const messages = messageGroup.get(messageIndex);
if (!messages) {
return;
}
messageGroup.set(
messageIndex,
messages.map((msg) => {
if (msg.id === messageId) {
msg.state = state;
}
return msg;
}),
);
}),
);
},
setDialogOpen: (dialog: DialogVariant, open: boolean) => { setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
@ -667,17 +579,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}), }),
); );
}, },
setQueueStatus: (status: QueueStatus) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.queueStatus = status;
device.queueStatus.free >= 10 ? true : false
}
}),
);
},
setNodeError: (nodeNum, error) => { setNodeError: (nodeNum, error) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {

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

@ -0,0 +1,372 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
useMessageStore,
MessageType,
MessageState,
type Message,
} from './messageStore.ts';
let memoryStorage: Record<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({});
});
});
});

234
src/core/stores/messageStore.ts

@ -0,0 +1,234 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { produce } from 'immer';
import { Types } from '@meshtastic/core';
import { zustandIndexDBStorage } from "./storage/indexDB.ts";
export enum MessageState {
Ack = "ack",
Waiting = "waiting",
Failed = "failed",
}
export enum MessageType {
Direct = "direct",
Broadcast = "broadcast",
}
interface MessageBase {
channel: Types.ChannelNumber;
to: number;
from: number;
date: number;
messageId: number;
state: MessageState;
message: string;
}
interface GenericMessage<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,
}),
}
));

14
src/core/stores/storage/indexDB.ts

@ -0,0 +1,14 @@
import { StateStorage } from "zustand/middleware";
import { get, set, del } from "idb-keyval";
export const zustandIndexDBStorage: StateStorage = {
getItem: async (name: string): Promise<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);
},
};

15
src/core/subscriptions.ts

@ -1,9 +1,12 @@
import type { Device } from "@core/stores/deviceStore.ts"; import type { Device } from "@core/stores/deviceStore.ts";
import { MeshDevice, Protobuf } from "@meshtastic/core"; import { MeshDevice, Protobuf } from "@meshtastic/core";
import type { MessageStore } from "@core/stores/messageStore.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
export const subscribeAll = ( export const subscribeAll = (
device: Device, device: Device,
connection: MeshDevice, connection: MeshDevice,
messageStore: MessageStore
) => { ) => {
let myNodeNum = 0; let myNodeNum = 0;
@ -51,6 +54,7 @@ export const subscribeAll = (
connection.events.onMyNodeInfo.subscribe((nodeInfo) => { connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
device.setHardware(nodeInfo); device.setHardware(nodeInfo);
messageStore.setNodeNum(nodeInfo.myNodeNum);
myNodeNum = nodeInfo.myNodeNum; myNodeNum = nodeInfo.myNodeNum;
}); });
@ -81,10 +85,10 @@ export const subscribeAll = (
connection.events.onMessagePacket.subscribe((messagePacket) => { connection.events.onMessagePacket.subscribe((messagePacket) => {
device.addMessage({ // incoming and outgoing messages are handled by this event listener
...messagePacket, const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
state: messagePacket.from !== myNodeNum ? "ack" : "waiting", const message = dto.toMessage();
}); messageStore.saveMessage(message);
}); });
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => { connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
@ -105,9 +109,6 @@ export const subscribeAll = (
}); });
}); });
connection.events.onQueueStatus.subscribe((queueStatus) => {
device.setQueueStatus(queueStatus);
});
connection.events.onRoutingPacket.subscribe((routingPacket) => { connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") { if (routingPacket.data.variant.case === "errorReason") {

70
src/pages/Messages.tsx

@ -1,4 +1,3 @@
import { useAppStore } from "../core/stores/appStore.ts";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx"; import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
import { PageLayout } from "@components/PageLayout.tsx"; import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
@ -14,11 +13,14 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
export const MessagesPage = () => { export const MessagesPage = () => {
const { channels, nodes, hardware, messages, hasNodeError } = useDevice(); const { channels, nodes, hardware, hasNodeError } = useDevice();
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore(); const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const filteredNodes = Array.from(nodes.values()).filter((node) => { const filteredNodes = Array.from(nodes.values()).filter((node) => {
if (node.num === hardware.myNodeNum) return false; if (node.num === hardware.myNodeNum) return false;
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
@ -29,14 +31,15 @@ export const MessagesPage = () => {
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
); );
const currentChannel = channels.get(activeChat); const currentChannel = channels.get(activeChat);
const { toast } = useToast();
const node = nodes.get(activeChat);
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
const messageDestination = chatType === "direct" ? activeChat : "broadcast"; const otherNode = nodes.get(activeChat);
const messageChannel = chatType === "direct"
? Types.ChannelNumber.Primary const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
: activeChat;
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const currentChat = { type: chatType, id: activeChat };
return ( return (
<> <>
@ -50,9 +53,8 @@ export const MessagesPage = () => {
: channel.index === 0 : channel.index === 0
? "Primary" ? "Primary"
: `Ch ${channel.index}`} : `Ch ${channel.index}`}
active={activeChat === channel.index && chatType === "broadcast"}
onClick={() => { onClick={() => {
setChatType("broadcast"); setChatType(MessageType.Broadcast);
setActiveChat(channel.index); setActiveChat(channel.index);
}} }}
element={<HashIcon size={16} className="mr-2" />} element={<HashIcon size={16} className="mr-2" />}
@ -70,21 +72,21 @@ export const MessagesPage = () => {
/> />
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{filteredNodes.map((node) => ( {filteredNodes.map((otherNode) => (
<SidebarButton <SidebarButton
key={node.num} key={otherNode.num}
label={node.user?.longName ?? label={otherNode.user?.longName ??
`!${numberToHexUnpadded(node.num)}`} `!${numberToHexUnpadded(otherNode.num)}`}
active={activeChat === node.num && chatType === "direct"} active={activeChat === otherNode.num && chatType === "direct"}
onClick={() => { onClick={() => {
setChatType("direct"); setChatType(MessageType.Direct);
setActiveChat(node.num); setActiveChat(otherNode.num);
}} }}
element={ element={
<Avatar <Avatar
text={node.user?.shortName ?? node.num.toString()} text={otherNode?.user?.shortName ?? otherNode.num.toString()}
className={cn(hasNodeError(node.num) && "text-red-500")} className={cn(hasNodeError(otherNode?.num) && "text-red-500")}
showError={hasNodeError(node.num)} showError={hasNodeError(otherNode?.num)}
size="sm" size="sm"
/> />
} }
@ -96,13 +98,13 @@ export const MessagesPage = () => {
<div className="flex flex-col w-full h-full container mx-auto"> <div className="flex flex-col w-full h-full container mx-auto">
<PageLayout <PageLayout
className="flex flex-col h-full" className="flex flex-col h-full"
label={`Messages: ${chatType === "broadcast" && currentChannel label={`Messages: ${MessageType.Broadcast && currentChannel
? getChannelName(currentChannel) ? getChannelName(currentChannel)
: chatType === "direct" && nodes.get(activeChat) : chatType === MessageType.Direct && nodes.get(activeChat)
? (nodes.get(activeChat)?.user?.longName ?? nodeHex) ? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
: "Loading..." : "Loading..."
}`} }`}
actions={chatType === "direct" actions={chatType === MessageType.Direct
? [ ? [
{ {
icon: nodes.get(activeChat)?.user?.publicKey.length icon: nodes.get(activeChat)?.user?.publicKey.length
@ -125,23 +127,24 @@ export const MessagesPage = () => {
: []} : []}
> >
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{chatType === "broadcast" && currentChannel && ( {isBroadcast && currentChannel && (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ChannelChat <ChannelChat
key={currentChannel.index} messages={getMessages(MessageType.Broadcast, {
messages={messages.broadcast.get(currentChannel.index)} myNodeNum: getNodeNum(),
channel: currentChannel?.index
})}
/> />
</div> </div>
</div> </div>
)} )}
{chatType === "direct" && node && ( {isDirect && otherNode && (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ChannelChat <ChannelChat
key={node.num} messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
messages={messages.direct.get(node.num)}
/> />
</div> </div>
</div> </div>
@ -150,8 +153,9 @@ export const MessagesPage = () => {
<div className="shrink-0 p-4 w-full dark:bg-slate-900"> <div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput <MessageInput
to={messageDestination} from={activeChat}
channel={messageChannel} to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast}
channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id}
maxBytes={200} maxBytes={200}
/> />
</div> </div>

8
src/tests/setupTests.ts

@ -1,11 +1,9 @@
import { expect, afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers'; import { enableMapSet } from "immer";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
// Enable auto mocks for our UI components enableMapSet();
//vi.mock('@components/UI/Dialog.tsx');
//vi.mock('@components/UI/Typography/Link.tsx');
globalThis.ResizeObserver = class { globalThis.ResizeObserver = class {
observe() { } observe() { }

2
vitest.config.ts

@ -2,6 +2,8 @@ import path from "node:path";
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import { enableMapSet } from "immer";
enableMapSet();
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),

Loading…
Cancel
Save