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"; } from "@components/UI/Command.tsx";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts"; import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
import { use } from "chai";
import { useCommandState } from "cmdk"; import { useCommandState } from "cmdk";
import { import {
ArrowLeftRightIcon, ArrowLeftRightIcon,
@ -32,6 +33,8 @@ import {
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useMap } from "react-map-gl/maplibre";
import { useMessageStore } from "@core/stores/messageStore.ts";
export interface Group { export interface Group {
label: string; label: string;
@ -61,6 +64,7 @@ export const CommandPalette = () => {
selectedDevice, selectedDevice,
} = useAppStore(); } = useAppStore();
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { clearAllMessages } = useMessageStore();
const { setDialogOpen, setActivePage, connection } = useDevice(); const { setDialogOpen, setActivePage, connection } = useDevice();
const groups: Group[] = [ const groups: Group[] = [
@ -221,10 +225,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"); 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 { TrashIcon } from "lucide-react";
import { Button } from "../UI/Button.tsx"; import { Button } from "../UI/Button.tsx";
import { useMessageStore } from "@core/stores/messageStore.ts"; import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
export interface NodeOptionsDialogProps { export interface NodeOptionsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined; node: Protobuf.Mesh.NodeInfo | undefined;
@ -41,7 +41,7 @@ export const NodeOptionsDialog = ({
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK"); (node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
function handleDirectMessage() { function handleDirectMessage() {
setChatType("direct"); setChatType(MessageType.Direct);
setActiveChat(node.num); setActiveChat(node.num);
setActivePage("messages"); 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 { 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()
})); }));

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

@ -55,7 +55,7 @@ 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) => (
<MessageItem <MessageItem
key={message.messageId + index} 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 { 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');
}); });
}); });

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 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 { ChatTypes, useMessageStore } from "@core/stores/messageStore.ts"; import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
import { debounce } from "@core/utils/debounce.ts"; import { debounce } from "@core/utils/debounce.ts";
export interface MessageInputProps { export interface MessageInputProps {
@ -13,6 +13,7 @@ export interface MessageInputProps {
maxBytes: number; maxBytes: number;
} }
export const MessageInput = ({ export const MessageInput = ({
to, to,
channel, channel,
@ -31,13 +32,13 @@ export const MessageInput = ({
const calculateBytes = (text: string) => new Blob([text]).size; 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) => { const sendText = useCallback(async (message: string) => {
try { try {
const messageId = await connection?.sendText(message, to, true, channel); const messageId = await connection?.sendText(message, to, true, channel);
if (messageId !== undefined) { 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 // deno-lint-ignore no-explicit-any
} catch (e: any) { } catch (e: any) {
@ -45,7 +46,7 @@ export const MessageInput = ({
type: chatType, type: chatType,
key: activeChat, key: activeChat,
messageId: e?.id, messageId: e?.id,
newState: 'failed', newState: MessageState.Failed,
}); });
} }
}, [channel, connection, setMessageState, to, activeChat, chatType]); }, [channel, connection, setMessageState, to, activeChat, chatType]);
@ -75,7 +76,7 @@ export const MessageInput = ({
return ( return (
<div className="flex gap-2"> <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"> <div className="flex grow gap-2">
<label className="w-full"> <label className="w-full">
<Input <Input

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

@ -5,13 +5,14 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@components/UI/Tooltip.tsx"; } 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 { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx"; 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 } from "@core/stores/messageStore.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,77 +48,109 @@ 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 }) => {
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
const _date = new Date(date); const _date = new Date(date);
return (<div className={cn("flex items-center gap-2 shrink-0", className)}> const locale = 'en-US'; // TODO: this should be dynamic based on user settings
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{_date?.toLocaleDateString()}</span> return (
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono"> <div className={cn("flex items-center gap-1 text-xs font-mono", className)}>
{_date?.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} <span>
{_date?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true })}
</span> </span>
</div>) {/* TODO: Conditionally show date for older messages? */}
</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], 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 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>
); );
}; };

32
src/core/dto/PacketToMessageDTO.ts

@ -5,23 +5,33 @@ class PacketToMessageDTO {
channel: Types.ChannelNumber; channel: Types.ChannelNumber;
to: number; to: number;
from: number; from: number;
date: string; date: number; // (timestamp ms)
messageId: number; messageId: number;
state: MessageState; state: MessageState;
message: string; message: string;
type: MessageType; type: MessageType;
constructor(data: Types.PacketMetadata<string>, nodeNum: number) { 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 let dateTimestamp = Date.now();
this.to = payload.to; if (data.rxTime instanceof Date) {
this.from = payload.from; const timeValue = data.rxTime.getTime();
this.date = new Date(payload.rxTime).toISOString();
this.messageId = payload.id; if (!isNaN(timeValue)) {
this.state = payload.from !== nodeNum ? "ack" : "waiting"; dateTimestamp = timeValue;
this.message = payload.data; }
this.type = payload.type; }
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 { toMessage(): Message {
@ -34,7 +44,7 @@ class PacketToMessageDTO {
state: this.state, state: this.state,
message: this.message, message: this.message,
type: this.type, 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 { 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', () => ({ vi.mock('./storage/indexDB.ts', () => ({
zustandIndexDBStorage: { zustandIndexDBStorage: {
@ -12,10 +13,10 @@ vi.mock('./storage/indexDB.ts', () => ({
beforeEach(() => { beforeEach(() => {
useMessageStore.setState({ useMessageStore.setState({
messages: { direct: {}, broadcast: {} }, messages: { direct: {}, broadcast: {} },
draft: new Map(), draft: new Map<Types.Destination, string>(),
nodeNum: 0, nodeNum: 0,
activeChat: 0, activeChat: 0,
chatType: 'broadcast', chatType: MessageType.Broadcast,
}); });
}); });
@ -25,163 +26,312 @@ describe('useMessageStore', () => {
expect(useMessageStore.getState().getNodeNum()).toBe(42); 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 = { const message: Message = {
type: 'direct', type: MessageType.Direct,
channel: 0, channel: 0,
to: 101, to: 101,
from: 202, from: 202,
date: new Date().toISOString(), date: Date.now(),
messageId: 1, messageId: 1,
state: 'waiting', state: MessageState.Waiting,
message: 'Hello Direct', message: 'Hello Direct',
}; };
useMessageStore.getState().saveMessage(message); 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 = { 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, channel: 0,
to: 101, to: 101,
from: 202, from: 202,
date: new Date().toISOString(), date: now,
messageId: 1, 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', message: 'Change me',
}; };
useMessageStore.getState().saveMessage(message); 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', () => { it('clears all messages', () => {
useMessageStore.getState().saveMessage({ useMessageStore.getState().saveMessage({
type: 'broadcast', type: MessageType.Broadcast,
channel: 5, channel: 5,
to: 0, to: 0,
from: 0, from: 303,
date: new Date().toISOString(), date: Date.now(),
messageId: 100, messageId: 100,
state: 'waiting', state: MessageState.Waiting,
message: 'Broadcast Message', 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.direct).toEqual({});
expect(useMessageStore.getState().messages.broadcast).toEqual({}); expect(useMessageStore.getState().messages.broadcast).toEqual({});
}); });
it('retrieves sorted broadcast messages', () => { describe('getMessages', () => {
const earlier = new Date(Date.now() - 10000).toISOString(); it('retrieves sorted broadcast messages for a channel', () => {
const later = new Date().toISOString(); const now = Date.now();
const earlier = now - 10000;
const later = now;
useMessageStore.getState().saveMessage({ useMessageStore.getState().saveMessage({
type: 'broadcast', type: MessageType.Broadcast,
channel: 4, channel: 4,
to: 0, to: 0,
from: 0, from: 404,
date: later, date: later,
messageId: 2, messageId: 2,
state: 'waiting', state: MessageState.Waiting,
message: 'Second', message: 'Second',
}); });
useMessageStore.getState().saveMessage({ useMessageStore.getState().saveMessage({
type: 'broadcast', type: MessageType.Broadcast,
channel: 4, channel: 4,
to: 0, to: 0,
from: 0, from: 404,
date: earlier, date: earlier,
messageId: 1, messageId: 1,
state: 'waiting', state: MessageState.Waiting,
message: 'First', 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.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('returns an empty array for broadcast messages if channel does not exist', () => {
it.skip('merges and sorts direct messages by date', () => { const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 99 });
const now = new Date(); expect(messages).toEqual([]);
const earlier = new Date(now.getTime() - 10000).toISOString(); });
const later = new Date(now.getTime() + 10000).toISOString();
useMessageStore.getState().saveMessage({ it('merges and sorts direct messages by date', () => {
type: 'direct', 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, channel: 0,
to: 1, // I am node 1 to: myNodeNum,
from: 2, // from node 2 from: otherNodeNum,
date: earlier, date: earlier,
messageId: 1, messageId: 1,
state: 'waiting', state: MessageState.Ack,
message: 'Incoming', message: 'Incoming from 2',
}); };
useMessageStore.getState().saveMessage({ useMessageStore.getState().saveMessage(incomingMessage);
type: 'direct',
const outgoingMessage: Message = {
type: MessageType.Direct,
channel: 0, channel: 0,
to: 2, // to node 2 to: otherNodeNum,
from: 1, // I am node 1 from: myNodeNum,
date: later, date: later,
messageId: 2, messageId: 2,
state: 'waiting', state: MessageState.Waiting,
message: 'Outgoing', 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', { expect(merged.length).toBe(2);
myNodeNum: 2, expect(merged.map((m) => m.message)).toEqual(['Incoming from 2', 'Outgoing from 1']);
otherNodeNum: 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', () => { it('gets an empty string if no draft exists for a key', () => {
useMessageStore.getState().setDraft(123, 'Draft text'); const key: Types.Destination = 456;
expect(useMessageStore.getState().getDraft(123)).toBe('Draft text'); expect(useMessageStore.getState().getDraft(key)).toBe('');
}); });
it('clears a draft', () => { it('clears a draft message', () => {
useMessageStore.getState().setDraft(123, 'Draft to clear'); const key: Types.Destination = 123;
useMessageStore.getState().clearDraft(123); useMessageStore.getState().setDraft(key, 'Draft to clear');
expect(useMessageStore.getState().getDraft(123)).toBe(''); useMessageStore.getState().clearDraft(key);
expect(useMessageStore.getState().getDraft(key)).toBe('');
});
}); });
describe('clearMessageByMessageId', () => {
it('clears a direct message by messageId', () => { it('clears a direct message by messageId', () => {
const message: Message = { const message: Message = {
type: 'direct', type: MessageType.Direct,
channel: 0, channel: 0,
to: 111, to: 111,
from: 222, from: 222,
date: new Date().toISOString(), date: Date.now(),
messageId: 42, messageId: 42,
state: 'waiting', state: MessageState.Waiting,
message: 'To be deleted', message: 'To be deleted',
}; };
useMessageStore.getState().saveMessage(message); 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]?.[42]).toBeUndefined();
expect(useMessageStore.getState().messages.direct[111]).toBeUndefined();
}); });
it('clears a broadcast message by messageId', () => { it('clears a broadcast message by messageId', () => {
const message: Message = { const message: Message = {
type: 'broadcast', type: MessageType.Broadcast,
channel: 2, channel: 2,
to: 0, to: 0,
from: 0, from: 333,
date: new Date().toISOString(), date: Date.now(),
messageId: 77, messageId: 77,
state: 'waiting', state: MessageState.Waiting,
message: 'Broadcast to delete', message: 'Broadcast to delete',
}; };
useMessageStore.getState().saveMessage(message); 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]?.[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 { Types } from '@meshtastic/core';
import { zustandIndexDBStorage } from "./storage/indexDB.ts"; import { zustandIndexDBStorage } from "./storage/indexDB.ts";
const MESSAGE_STATES = { export enum MessageState {
ack: "ack", Ack = "ack",
waiting: "waiting", Waiting = "waiting",
failed: 'failed', Failed = "failed",
}; }
export type MessageState = keyof typeof MESSAGE_STATES;
export const ChatTypes = {
DIRECT: "direct",
BROADCAST: "broadcast",
} as const;
export type MessageType = "broadcast" | "direct"; export enum MessageType {
Direct = "direct",
Broadcast = "broadcast",
}
interface MessageBase { interface MessageBase {
channel: Types.ChannelNumber; channel: Types.ChannelNumber;
to: number; to: number;
from: number; from: number;
date: string; date: number; // Unix timestamp in milliseconds
messageId: number; messageId: number;
state: MessageState; state: MessageState;
message: string; message: string;
@ -32,7 +29,7 @@ interface GenericMessage<T extends MessageType> extends MessageBase {
type: T; type: T;
} }
export type Message = GenericMessage<'direct'> | GenericMessage<'broadcast'>; export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
export interface MessageStore { export interface MessageStore {
messages: { messages: {
@ -55,10 +52,10 @@ export interface MessageStore {
messageId: number; messageId: number;
newState?: MessageState; newState?: MessageState;
}) => void; }) => void;
clearMessages: () => void;
getMessages: (type: MessageType, options: { myNodeNum?: number; otherNodeNum?: number; channel?: number }) => Message[]; getMessages: (type: MessageType, options: { myNodeNum?: number; otherNodeNum?: number; channel?: number }) => Message[];
getDraft: (key: Types.Destination) => string; getDraft: (key: Types.Destination) => string;
setDraft: (key: Types.Destination, message: string) => void; setDraft: (key: Types.Destination, message: string) => void;
clearAllMessages: () => void;
clearMessageByMessageId: (type: MessageType, messageId: number) => void; clearMessageByMessageId: (type: MessageType, messageId: number) => void;
clearDraft: (key: Types.Destination) => void; clearDraft: (key: Types.Destination) => void;
@ -73,7 +70,7 @@ export const useMessageStore = create<MessageStore>()(
}, },
draft: new Map<number, string>(), draft: new Map<number, string>(),
activeChat: 0, activeChat: 0,
chatType: 'broadcast', chatType: MessageType.Broadcast,
nodeNum: 0, nodeNum: 0,
setNodeNum: (nodeNum) => { setNodeNum: (nodeNum) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
@ -94,22 +91,32 @@ export const useMessageStore = create<MessageStore>()(
saveMessage: (message) => { saveMessage: (message) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
const group = state.messages[message.type]; 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]) { if (!group[key]) {
group[key] = {}; group[key] = {};
} }
group[key][message.messageId] = message; const messageToSave = { ...message };
group[key][message.messageId] = messageToSave;
})); }));
}, },
setMessageState: ({ type, key, messageId, newState = 'ack' }) => { setMessageState: ({
const group = get().messages[type]; type,
const messageMap = group[key]; key,
messageId,
if (!messageMap || !messageMap[messageId]) return; newState = MessageState.Ack,
}) => {
set(produce((state: MessageStore) => { set(
state.messages[type][key][messageId].state = newState; 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: () => { clearMessages: () => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
@ -120,24 +127,26 @@ export const useMessageStore = create<MessageStore>()(
getMessages: (type, options) => { getMessages: (type, options) => {
const state = get(); const state = get();
if (type === 'broadcast' && options.channel !== undefined) { if (type === MessageType.Broadcast && options.channel !== undefined) {
const messageMap = state.messages.broadcast[options.channel] ?? {}; 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) { if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
const receivedMap = state.messages.direct[options.otherNodeNum] ?? {}; // Messages TO the other node (sent by me) are keyed under their nodeNum
const sentMap = state.messages.direct[options.myNodeNum] ?? {}; const messagesToOtherNodeMap = state.messages.direct[options.otherNodeNum] ?? {};
const sentByMe = Object.values(messagesToOtherNodeMap);
// Pull messages where I am the sender and otherNode is the receiver
const sentMessages = Object.values(sentMap).filter(msg => msg.to === options.otherNodeNum);
// Pull messages received from otherNode // Messages TO me (potentially from the other node) are keyed under my nodeNum
const receivedMessages = Object.values(receivedMap); 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 // Merge and sort chronologically
return [...receivedMessages, ...sentMessages].sort( return [...sentByMe, ...sentByOtherNode].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() (a, b) => a.date - b.date
); );
} }
@ -170,6 +179,12 @@ export const useMessageStore = create<MessageStore>()(
state.draft.delete(key); state.draft.delete(key);
})); }));
}, },
clearAllMessages: () => {
set(produce((state: MessageStore) => {
state.messages.direct = {};
state.messages.broadcast = {};
}));
}
}), }),
{ {
name: 'meshtastic-message-store', 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 { 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 { ChatTypes, useMessageStore } from "@core/stores/messageStore.ts"; import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
export const MessagesPage = () => { export const MessagesPage = () => {
const { channels, nodes, hardware, hasNodeError } = useDevice(); const { channels, nodes, hardware, hasNodeError } = useDevice();
@ -36,8 +36,8 @@ export const MessagesPage = () => {
const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown"; const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
const isDirect = chatType === ChatTypes.DIRECT; const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === ChatTypes.BROADCAST; const isBroadcast = chatType === MessageType.Broadcast;
const currentChat = { type: chatType, id: activeChat }; const currentChat = { type: chatType, id: activeChat };
@ -54,7 +54,7 @@ export const MessagesPage = () => {
? "Primary" ? "Primary"
: `Ch ${channel.index}`} : `Ch ${channel.index}`}
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" />}
@ -79,7 +79,7 @@ export const MessagesPage = () => {
`!${numberToHexUnpadded(otherNode.num)}`} `!${numberToHexUnpadded(otherNode.num)}`}
active={activeChat === otherNode.num && chatType === "direct"} active={activeChat === otherNode.num && chatType === "direct"}
onClick={() => { onClick={() => {
setChatType("direct"); setChatType(MessageType.Direct);
setActiveChat(otherNode.num); setActiveChat(otherNode.num);
}} }}
element={ element={
@ -98,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
@ -131,7 +131,7 @@ export const MessagesPage = () => {
<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
messages={getMessages('broadcast', { messages={getMessages(MessageType.Broadcast, {
myNodeNum: getNodeNum(), myNodeNum: getNodeNum(),
channel: currentChannel?.index channel: currentChannel?.index
})} })}
@ -144,7 +144,7 @@ export const MessagesPage = () => {
<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
messages={getMessages('direct', { myNodeNum: getNodeNum(), otherNodeNum: activeChat })} messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
/> />
</div> </div>
</div> </div>
@ -153,8 +153,8 @@ 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={currentChat.type === ChatTypes.DIRECT ? activeChat : ChatTypes.BROADCAST} to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast}
channel={currentChat.type === ChatTypes.DIRECT ? Types.ChannelNumber.Primary : currentChat.id} channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id}
maxBytes={200} maxBytes={200}
/> />
</div> </div>

Loading…
Cancel
Save