add enums, improve tests, add styling
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
} from "@components/UI/Command.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { use } from "chai";
|
||||
import { useCommandState } from "cmdk";
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useMap } from "react-map-gl/maplibre";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
export interface Group {
|
||||
label: string;
|
||||
@@ -61,6 +64,7 @@ export const CommandPalette = () => {
|
||||
selectedDevice,
|
||||
} = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { clearAllMessages } = useMessageStore();
|
||||
const { setDialogOpen, setActivePage, connection } = useDevice();
|
||||
|
||||
const groups: Group[] = [
|
||||
@@ -117,7 +121,7 @@ export const CommandPalette = () => {
|
||||
return {
|
||||
label:
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Avatar
|
||||
text={device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
@@ -221,10 +225,10 @@ export const CommandPalette = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "[WIP] Clear Messages",
|
||||
label: "Clear All Stored Message",
|
||||
icon: EraserIcon,
|
||||
action() {
|
||||
alert("This feature is not implemented");
|
||||
void clearAllMessages();
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,7 +13,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
export interface NodeOptionsDialogProps {
|
||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||
@@ -41,7 +41,7 @@ export const NodeOptionsDialog = ({
|
||||
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
|
||||
|
||||
function handleDirectMessage() {
|
||||
setChatType("direct");
|
||||
setChatType(MessageType.Direct);
|
||||
setActiveChat(node.num);
|
||||
setActivePage("messages");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog";
|
||||
import { eventBus } from "@core/utils/eventBus";
|
||||
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
|
||||
vi.mock('@core/utils/eventBus', () => ({
|
||||
eventBus: {
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock('@core/stores/deviceStore', () => ({
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
useDevice: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
|
||||
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({
|
||||
useUnsafeRolesDialog: vi.fn()
|
||||
}));
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export const ChannelChat = ({
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
|
||||
>
|
||||
<div className="flex flex-col justify-end min-h-full">
|
||||
<div className="flex flex-col gap-1.5 justify-end min-h-full">
|
||||
{messages?.map((message, index) => (
|
||||
<MessageItem
|
||||
key={message.messageId + index}
|
||||
|
||||
@@ -1,152 +1,154 @@
|
||||
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx';
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MessageInput } from './MessageInput.tsx';
|
||||
import { useDevice } from '@core/stores/deviceStore.ts';
|
||||
import { useMessageStore } from '@core/stores/messageStore.ts';
|
||||
import { debounce } from '@core/utils/debounce.ts';
|
||||
import { Types } from "@meshtastic/core";
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
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('@components/UI/Input.tsx', () => ({
|
||||
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
minLength={minLength}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
useDevice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@core/utils/debounce.ts", () => ({
|
||||
debounce: (fn: () => void) => fn,
|
||||
vi.mock('@core/stores/messageStore.ts', () => ({
|
||||
useMessageStore: vi.fn(),
|
||||
MessageState: {
|
||||
Ack: 'ack',
|
||||
Waiting: 'waiting',
|
||||
Failed: 'failed',
|
||||
},
|
||||
MessageType: {
|
||||
Direct: 'direct',
|
||||
Broadcast: 'broadcast',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@components/UI/Button.tsx", () => ({
|
||||
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>
|
||||
vi.mock('@core/utils/debounce.ts', () => ({
|
||||
debounce: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@components/UI/Input.tsx", () => ({
|
||||
Input: (props: any) => <input {...props} />
|
||||
vi.mock('lucide-react', () => ({
|
||||
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
SendIcon: () => <div data-testid="send-icon">Send</div>
|
||||
}));
|
||||
|
||||
// TODO: getting an error with this test
|
||||
describe('MessageInput Component', () => {
|
||||
const mockProps = {
|
||||
to: "broadcast" as const,
|
||||
channel: 0 as const,
|
||||
maxBytes: 100,
|
||||
};
|
||||
|
||||
const mockSetMessageDraft = vi.fn();
|
||||
describe('MessageInput', () => {
|
||||
const mockSetMessageState = vi.fn();
|
||||
const mockSendText = vi.fn().mockResolvedValue(123);
|
||||
const mockSetActiveChat = vi.fn();
|
||||
const mockSetDraft = vi.fn();
|
||||
const mockGetDraft = vi.fn();
|
||||
const mockClearDraft = vi.fn();
|
||||
const mockSendText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
(useDevice as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
connection: {
|
||||
sendText: mockSendText,
|
||||
},
|
||||
setMessageState: mockSetMessageState,
|
||||
messageDraft: "",
|
||||
setMessageDraft: mockSetMessageDraft,
|
||||
hardware: {
|
||||
myNodeNum: 1234567890,
|
||||
},
|
||||
});
|
||||
|
||||
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
setMessageState: mockSetMessageState,
|
||||
activeChat: 123,
|
||||
setDraft: mockSetDraft,
|
||||
getDraft: mockGetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
});
|
||||
|
||||
mockSetMessageState.mockClear();
|
||||
mockSetActiveChat.mockClear();
|
||||
mockSetDraft.mockClear();
|
||||
mockGetDraft.mockClear();
|
||||
mockClearDraft.mockClear();
|
||||
mockSendText.mockClear();
|
||||
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn);
|
||||
});
|
||||
|
||||
it('renders correctly with initial state', () => {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => {
|
||||
render(<MessageInput {...props} />);
|
||||
};
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates local draft and byte count when typing', () => {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
|
||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||
fireEvent.change(inputField, { target: { value: 'Hello' } })
|
||||
|
||||
expect(screen.getByText('5/100')).toBeInTheDocument();
|
||||
expect(inputField).toHaveValue('Hello');
|
||||
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
|
||||
});
|
||||
|
||||
it.skip('does not allow input exceeding max bytes', () => {
|
||||
render(<MessageInput {...mockProps} maxBytes={5} />);
|
||||
|
||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||
|
||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||
|
||||
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p')
|
||||
|
||||
expect(screen.getByText('100/100')).toBeInTheDocument();
|
||||
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
|
||||
});
|
||||
|
||||
it.skip('sends message and resets form when submitting', async () => {
|
||||
try {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
|
||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||
const submitButton = screen.getByText('Send');
|
||||
|
||||
fireEvent.change(inputField, { target: { value: 'Test Message' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const form = screen.getByRole('form');
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith(
|
||||
'broadcast',
|
||||
0,
|
||||
'broadcast',
|
||||
1234567890,
|
||||
123,
|
||||
'ack'
|
||||
);
|
||||
it.skip('sends text message and updates state to Ack on submit', async () => {
|
||||
renderComponent({ to: 2, channel: 3, maxBytes: 256 });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
fireEvent.change(inputElement, { target: { value: 'Hello' } });
|
||||
const formElement = screen.getByRole('form');
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'direct',
|
||||
key: 123,
|
||||
messageId: undefined,
|
||||
newState: 'ack',
|
||||
});
|
||||
|
||||
expect(inputField).toHaveValue('');
|
||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
it('prevents sending empty messages', () => {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
|
||||
const form = screen.getByPlaceholderText('Enter Message')
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(mockSendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initializes with existing message draft', () => {
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
connection: {
|
||||
sendText: mockSendText,
|
||||
},
|
||||
setMessageState: mockSetMessageState,
|
||||
messageDraft: "Existing draft",
|
||||
setMessageDraft: mockSetMessageDraft,
|
||||
isQueueingMessages: false,
|
||||
queueStatus: { free: 10 },
|
||||
hardware: {
|
||||
myNodeNum: 1234567890,
|
||||
},
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(2);
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
});
|
||||
});
|
||||
|
||||
render(<MessageInput {...mockProps} />);
|
||||
it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => {
|
||||
renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
fireEvent.change(inputElement, { target: { value: 'Broadcast message' } });
|
||||
const formElement = screen.getByRole('form');
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
const inputField = screen.getByRole('textbox');
|
||||
await waitFor(() => {
|
||||
expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'broadcast',
|
||||
key: 123,
|
||||
messageId: undefined,
|
||||
newState: 'ack',
|
||||
});
|
||||
expect(mockClearDraft).toHaveBeenCalledWith('broadcast');
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
});
|
||||
});
|
||||
|
||||
expect(inputField).toHaveValue('Existing draft');
|
||||
it('updates state to Failed if sendText throws an error', async () => {
|
||||
mockSendText.mockRejectedValue({ id: 456 });
|
||||
renderComponent({ to: 3, channel: 1, maxBytes: 256 });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
fireEvent.change(inputElement, { target: { value: 'Error message' } });
|
||||
const formElement = screen.getByRole('form');
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'direct',
|
||||
key: 123,
|
||||
messageId: 456,
|
||||
newState: 'failed',
|
||||
});
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(3);
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||
import { ChatTypes, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { debounce } from "@core/utils/debounce.ts";
|
||||
|
||||
export interface MessageInputProps {
|
||||
@@ -13,6 +13,7 @@ export interface MessageInputProps {
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
|
||||
export const MessageInput = ({
|
||||
to,
|
||||
channel,
|
||||
@@ -31,13 +32,13 @@ export const MessageInput = ({
|
||||
|
||||
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||
|
||||
const chatType = to === 'broadcast' ? ChatTypes.BROADCAST : ChatTypes.DIRECT;
|
||||
const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct;
|
||||
|
||||
const sendText = useCallback(async (message: string) => {
|
||||
try {
|
||||
const messageId = await connection?.sendText(message, to, true, channel);
|
||||
if (messageId !== undefined) {
|
||||
setMessageState({ type: chatType, key: activeChat, messageId, newState: 'ack' });
|
||||
setMessageState({ type: chatType, key: activeChat, messageId, newState: MessageState.Ack });
|
||||
}
|
||||
// deno-lint-ignore no-explicit-any
|
||||
} catch (e: any) {
|
||||
@@ -45,7 +46,7 @@ export const MessageInput = ({
|
||||
type: chatType,
|
||||
key: activeChat,
|
||||
messageId: e?.id,
|
||||
newState: 'failed',
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
}
|
||||
}, [channel, connection, setMessageState, to, activeChat, chatType]);
|
||||
@@ -75,7 +76,7 @@ export const MessageInput = ({
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<form className="w-full" onSubmit={handleSubmit}>
|
||||
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}>
|
||||
<div className="flex grow gap-2">
|
||||
<label className="w-full">
|
||||
<Input
|
||||
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { MessageState, useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ReactNode, useMemo } from "react";
|
||||
import { Message } from "@core/stores/messageStore.ts";
|
||||
import { Message, MessageState, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
|
||||
interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
@@ -25,24 +26,20 @@ interface MessageStatus {
|
||||
}
|
||||
|
||||
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
|
||||
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 },
|
||||
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis },
|
||||
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle },
|
||||
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2 },
|
||||
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis },
|
||||
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle },
|
||||
};
|
||||
|
||||
const getMessageStatus = (state: MessageState): MessageStatus =>
|
||||
MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle };
|
||||
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle };
|
||||
|
||||
|
||||
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
<TooltipContent /* ...props... */ >
|
||||
{status.displayText}
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
@@ -51,77 +48,109 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children:
|
||||
);
|
||||
|
||||
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
|
||||
const isFailed = status.state === "failed";
|
||||
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className);
|
||||
const isFailed = status.state === MessageState.Failed;
|
||||
const iconClass = cn("w-4 h-4 shrink-0", className);
|
||||
const Icon = status.icon;
|
||||
|
||||
return (
|
||||
<StatusTooltip status={status}>
|
||||
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
|
||||
<Icon className={iconClass} {...otherProps} color={isFailed ? "currentColor" : undefined} />
|
||||
</StatusTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageTextStyles = (status: MessageStatus) => {
|
||||
const isAcknowledged = status.state === "ack";
|
||||
const isFailed = status.state === "failed";
|
||||
|
||||
const getMessageTextStyles = (status: MessageState, isDeviceUser: boolean) => {
|
||||
const isFailed = status === MessageState.Failed;
|
||||
return cn(
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
"break-words overflow-hidden whitespace-pre-wrap flex items-center gap-1.5",
|
||||
isFailed && (isDeviceUser ? "text-red-500" : "text-red-600 dark:text-red-500")
|
||||
);
|
||||
};
|
||||
|
||||
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => {
|
||||
|
||||
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
|
||||
const _date = new Date(date);
|
||||
return (<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{_date?.toLocaleDateString()}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{_date?.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>)
|
||||
const locale = 'en-US'; // TODO: this should be dynamic based on user settings
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1 text-xs font-mono", className)}>
|
||||
<span>
|
||||
{_date?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true })}
|
||||
</span>
|
||||
{/* TODO: Conditionally show date for older messages? */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
||||
const myNodeNum = useMessageStore((state) => state.nodeNum);
|
||||
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const isDeviceUser = useMemo(
|
||||
() =>
|
||||
getDevices()
|
||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||
.includes(message.from),
|
||||
[getDevices, message.from],
|
||||
);
|
||||
const isDeviceUser = message.from === myNodeNum;
|
||||
|
||||
const messageUser = message?.from
|
||||
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
|
||||
: null;
|
||||
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
|
||||
if (message?.from === null || message?.from === undefined) return null;
|
||||
for (const device of getDevices()) {
|
||||
console.log("MessageItem: getDevices", { device });
|
||||
|
||||
if (device.nodes.has(message.from)) {
|
||||
console.log("MessageItem hasNode", { device, message });
|
||||
|
||||
return device.nodes.get(message.from) ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [getDevices, message.from]);
|
||||
|
||||
const fallbackName = `${message.from}`;
|
||||
const longName = messageUser?.user?.longName;
|
||||
const shortName = messageUser?.user?.shortName ?? fallbackName.slice(0, 2).toUpperCase();
|
||||
const displayName = isDeviceUser ? "You" : (longName || fallbackName);
|
||||
|
||||
const messageContainerClass = cn(
|
||||
"flex flex-col w-full px-4 justify-start",
|
||||
!lastMsgSameUser ? "pt-3" : "pt-0.5"
|
||||
);
|
||||
const alignmentClass = cn(
|
||||
"flex flex-col flex-wrap w-full",
|
||||
isDeviceUser ? "items-end" : "items-start"
|
||||
);
|
||||
const bubbleBaseStyle = "flex flex-col max-w-[75%] rounded-lg px-3 py-1.5 text-sm shadow-md";
|
||||
const sentBubbleStyle = "bg-gradient-to-br from-blue-600 to-blue-700 dark:from-blue-500 dark:to-blue-600 text-white";
|
||||
const receivedBubbleStyle = "bg-slate-200 dark:bg-slate-500 text-slate-900 dark:text-white";
|
||||
const timeStatusColor = isDeviceUser ? "text-blue-100 dark:text-blue-200" : "text-slate-500 dark:text-slate-300";
|
||||
|
||||
const messageStatus = getMessageStatus(message.state);
|
||||
const messageTextClass = getMessageTextStyles(messageStatus);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full px-4 justify-start">
|
||||
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.user?.longName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimeDisplay date={message.date} />
|
||||
<div className="flex place-items-center gap-2 pb-2">
|
||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
|
||||
<StatusIcon status={messageStatus} />
|
||||
<div className={messageContainerClass}>
|
||||
<div className={alignmentClass}>
|
||||
|
||||
{/* Show only if not consecutive message AND not sent by self */}
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex items-center gap-1.5 mb-1 px-1">
|
||||
<Avatar text={shortName} />
|
||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
bubbleBaseStyle,
|
||||
isDeviceUser ? sentBubbleStyle : receivedBubbleStyle
|
||||
)}>
|
||||
<div className={cn("flex items-center gap-1.5 mt-1 self-end", timeStatusColor)}>
|
||||
<TimeDisplay date={message.date} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,23 +5,33 @@ class PacketToMessageDTO {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: string;
|
||||
date: number; // (timestamp ms)
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
type: MessageType;
|
||||
|
||||
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
|
||||
const payload = data
|
||||
this.channel = data.channel;
|
||||
this.to = data.to;
|
||||
this.from = data.from;
|
||||
this.messageId = data.id;
|
||||
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
|
||||
this.message = data.data;
|
||||
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast;
|
||||
|
||||
this.channel = payload.channel
|
||||
this.to = payload.to;
|
||||
this.from = payload.from;
|
||||
this.date = new Date(payload.rxTime).toISOString();
|
||||
this.messageId = payload.id;
|
||||
this.state = payload.from !== nodeNum ? "ack" : "waiting";
|
||||
this.message = payload.data;
|
||||
this.type = payload.type;
|
||||
let dateTimestamp = Date.now();
|
||||
if (data.rxTime instanceof Date) {
|
||||
const timeValue = data.rxTime.getTime();
|
||||
|
||||
if (!isNaN(timeValue)) {
|
||||
dateTimestamp = timeValue;
|
||||
}
|
||||
}
|
||||
else if (data.rxTime != null) {
|
||||
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`);
|
||||
}
|
||||
this.date = dateTimestamp;
|
||||
}
|
||||
|
||||
toMessage(): Message {
|
||||
@@ -34,7 +44,7 @@ class PacketToMessageDTO {
|
||||
state: this.state,
|
||||
message: this.message,
|
||||
type: this.type,
|
||||
} as Message;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useMessageStore, Message } from './messageStore';
|
||||
import { useMessageStore, Message, MessageState, MessageType } from './messageStore.ts';
|
||||
import { Types } from '@meshtastic/core';
|
||||
|
||||
vi.mock('./storage/indexDB.ts', () => ({
|
||||
zustandIndexDBStorage: {
|
||||
@@ -12,10 +13,10 @@ vi.mock('./storage/indexDB.ts', () => ({
|
||||
beforeEach(() => {
|
||||
useMessageStore.setState({
|
||||
messages: { direct: {}, broadcast: {} },
|
||||
draft: new Map(),
|
||||
draft: new Map<Types.Destination, string>(),
|
||||
nodeNum: 0,
|
||||
activeChat: 0,
|
||||
chatType: 'broadcast',
|
||||
chatType: MessageType.Broadcast,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,163 +26,312 @@ describe('useMessageStore', () => {
|
||||
expect(useMessageStore.getState().getNodeNum()).toBe(42);
|
||||
});
|
||||
|
||||
it('saves and retrieves a direct message', () => {
|
||||
const message: Message = {
|
||||
type: 'direct',
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: new Date().toISOString(),
|
||||
messageId: 1,
|
||||
state: 'waiting',
|
||||
message: 'Hello Direct',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.direct[101][1]).toEqual(message);
|
||||
it('sets activeChat', () => {
|
||||
useMessageStore.getState().setActiveChat(123);
|
||||
expect(useMessageStore.getState().activeChat).toBe(123);
|
||||
});
|
||||
|
||||
it('updates message state', () => {
|
||||
const message: Message = {
|
||||
type: 'direct',
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: new Date().toISOString(),
|
||||
messageId: 1,
|
||||
state: 'waiting',
|
||||
message: 'Change me',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
useMessageStore.getState().setMessageState({ type: 'direct', key: 101, messageId: 1, newState: 'ack' });
|
||||
expect(useMessageStore.getState().messages.direct[101][1].state).toBe('ack');
|
||||
it('sets chatType', () => {
|
||||
useMessageStore.getState().setChatType(MessageType.Direct);
|
||||
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
|
||||
});
|
||||
|
||||
describe('saveMessage', () => {
|
||||
it('saves a direct message', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: Date.now(),
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello Direct',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.direct[101]?.[1]).toEqual(message);
|
||||
});
|
||||
|
||||
it('saves a 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);
|
||||
expect(useMessageStore.getState().messages.broadcast[5]?.[100]).toEqual(message);
|
||||
});
|
||||
|
||||
it('ensures date is stored as milliseconds', () => {
|
||||
const now = Date.now();
|
||||
const message: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: now,
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello Direct',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.direct[101]?.[1]?.date).toBe(new Date(now).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMessageState', () => {
|
||||
it('updates the state of an existing direct message', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: Date.now(),
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Change me',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: 101,
|
||||
messageId: 1,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
|
||||
expect(useMessageStore.getState().messages.direct[101]?.[1]?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('updates the state of an existing broadcast message', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
channel: 5,
|
||||
to: 0,
|
||||
from: 303,
|
||||
date: Date.now(),
|
||||
messageId: 100,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast Message',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
key: 5,
|
||||
messageId: 100,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
|
||||
expect(useMessageStore.getState().messages.broadcast[5]?.[100]?.state).toBe(MessageState.Failed);
|
||||
});
|
||||
|
||||
it('does not update if the message is not found and logs a warning', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: 999,
|
||||
messageId: 99,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Message not found - type: direct, key: 999, messageId: 99',
|
||||
);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears all messages', () => {
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: 'broadcast',
|
||||
type: MessageType.Broadcast,
|
||||
channel: 5,
|
||||
to: 0,
|
||||
from: 0,
|
||||
date: new Date().toISOString(),
|
||||
from: 303,
|
||||
date: Date.now(),
|
||||
messageId: 100,
|
||||
state: 'waiting',
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast Message',
|
||||
});
|
||||
useMessageStore.getState().clearMessages();
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: Date.now(),
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello Direct',
|
||||
});
|
||||
useMessageStore.getState().clearAllMessages();
|
||||
expect(useMessageStore.getState().messages.direct).toEqual({});
|
||||
expect(useMessageStore.getState().messages.broadcast).toEqual({});
|
||||
});
|
||||
|
||||
it('retrieves sorted broadcast messages', () => {
|
||||
const earlier = new Date(Date.now() - 10000).toISOString();
|
||||
const later = new Date().toISOString();
|
||||
describe('getMessages', () => {
|
||||
it('retrieves sorted broadcast messages for a channel', () => {
|
||||
const now = Date.now();
|
||||
const earlier = now - 10000;
|
||||
const later = now;
|
||||
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: 'broadcast',
|
||||
channel: 4,
|
||||
to: 0,
|
||||
from: 0,
|
||||
date: later,
|
||||
messageId: 2,
|
||||
state: 'waiting',
|
||||
message: 'Second',
|
||||
});
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: 'broadcast',
|
||||
channel: 4,
|
||||
to: 0,
|
||||
from: 0,
|
||||
date: earlier,
|
||||
messageId: 1,
|
||||
state: 'waiting',
|
||||
message: 'First',
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: MessageType.Broadcast,
|
||||
channel: 4,
|
||||
to: 0,
|
||||
from: 404,
|
||||
date: later,
|
||||
messageId: 2,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Second',
|
||||
});
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: MessageType.Broadcast,
|
||||
channel: 4,
|
||||
to: 0,
|
||||
from: 404,
|
||||
date: earlier,
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'First',
|
||||
});
|
||||
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 4 });
|
||||
expect(messages.map((m) => m.message)).toEqual(['First', 'Second']);
|
||||
expect(messages[0]?.date).toBe(earlier);
|
||||
expect(messages[1]?.date).toBe(later);
|
||||
});
|
||||
|
||||
const messages = useMessageStore.getState().getMessages('broadcast', { channel: 4 });
|
||||
expect(messages.map((m) => m.message)).toEqual(['First', 'Second']);
|
||||
});
|
||||
|
||||
// this test is failing and haven't had a chance to debug it
|
||||
it.skip('merges and sorts direct messages by date', () => {
|
||||
const now = new Date();
|
||||
const earlier = new Date(now.getTime() - 10000).toISOString();
|
||||
const later = new Date(now.getTime() + 10000).toISOString();
|
||||
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: 'direct',
|
||||
channel: 0,
|
||||
to: 1, // I am node 1
|
||||
from: 2, // from node 2
|
||||
date: earlier,
|
||||
messageId: 1,
|
||||
state: 'waiting',
|
||||
message: 'Incoming',
|
||||
});
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: 'direct',
|
||||
channel: 0,
|
||||
to: 2, // to node 2
|
||||
from: 1, // I am node 1
|
||||
date: later,
|
||||
messageId: 2,
|
||||
state: 'waiting',
|
||||
message: 'Outgoing',
|
||||
it('returns an empty array for broadcast messages if channel does not exist', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 99 });
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
const merged = useMessageStore.getState().getMessages('direct', {
|
||||
myNodeNum: 2,
|
||||
otherNodeNum: 1,
|
||||
it('merges and sorts direct messages by date', () => {
|
||||
const myNodeNum = 1;
|
||||
const otherNodeNum = 2;
|
||||
const now = Date.now();
|
||||
const earlier = now - 10000;
|
||||
const later = now + 10000;
|
||||
|
||||
const incomingMessage: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: myNodeNum,
|
||||
from: otherNodeNum,
|
||||
date: earlier,
|
||||
messageId: 1,
|
||||
state: MessageState.Ack,
|
||||
message: 'Incoming from 2',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(incomingMessage);
|
||||
|
||||
const outgoingMessage: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: otherNodeNum,
|
||||
from: myNodeNum,
|
||||
date: later,
|
||||
messageId: 2,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Outgoing from 1',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(outgoingMessage);
|
||||
|
||||
const merged = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: otherNodeNum,
|
||||
});
|
||||
|
||||
expect(merged.length).toBe(2);
|
||||
expect(merged.map((m) => m.message)).toEqual(['Incoming from 2', 'Outgoing from 1']);
|
||||
expect(merged[0]?.date).toBe(earlier);
|
||||
expect(merged[1]?.date).toBe(later);
|
||||
});
|
||||
|
||||
console.log(merged);
|
||||
|
||||
expect(merged.map(m => m.message)).toEqual(['Incoming', 'Outgoing']);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets and gets a draft', () => {
|
||||
useMessageStore.getState().setDraft(123, 'Draft text');
|
||||
expect(useMessageStore.getState().getDraft(123)).toBe('Draft text');
|
||||
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('gets an empty string if no draft exists for a key', () => {
|
||||
const key: Types.Destination = 456;
|
||||
expect(useMessageStore.getState().getDraft(key)).toBe('');
|
||||
});
|
||||
|
||||
it('clears a draft message', () => {
|
||||
const key: Types.Destination = 123;
|
||||
useMessageStore.getState().setDraft(key, 'Draft to clear');
|
||||
useMessageStore.getState().clearDraft(key);
|
||||
expect(useMessageStore.getState().getDraft(key)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a draft', () => {
|
||||
useMessageStore.getState().setDraft(123, 'Draft to clear');
|
||||
useMessageStore.getState().clearDraft(123);
|
||||
expect(useMessageStore.getState().getDraft(123)).toBe('');
|
||||
describe('clearMessageByMessageId', () => {
|
||||
it('clears a direct message by messageId', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 111,
|
||||
from: 222,
|
||||
date: Date.now(),
|
||||
messageId: 42,
|
||||
state: MessageState.Waiting,
|
||||
message: 'To be deleted',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.direct[111]?.[42]).toBeDefined();
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId(MessageType.Direct, 42);
|
||||
|
||||
expect(useMessageStore.getState().messages.direct[111]?.[42]).toBeUndefined();
|
||||
expect(useMessageStore.getState().messages.direct[111]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a broadcast message by messageId', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
channel: 2,
|
||||
to: 0,
|
||||
from: 333,
|
||||
date: Date.now(),
|
||||
messageId: 77,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast to delete',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.broadcast[2]?.[77]).toBeDefined();
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId(MessageType.Broadcast, 77);
|
||||
|
||||
expect(useMessageStore.getState().messages.broadcast[2]?.[77]).toBeUndefined();
|
||||
expect(useMessageStore.getState().messages.broadcast[2]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not throw error if trying to clear a non-existent message', () => {
|
||||
expect(() => {
|
||||
useMessageStore.getState().clearMessageByMessageId(MessageType.Direct, 999);
|
||||
useMessageStore.getState().clearMessageByMessageId(MessageType.Broadcast, 999);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a direct message by messageId', () => {
|
||||
const message: Message = {
|
||||
type: 'direct',
|
||||
channel: 0,
|
||||
to: 111,
|
||||
from: 222,
|
||||
date: new Date().toISOString(),
|
||||
messageId: 42,
|
||||
state: 'waiting',
|
||||
message: 'To be deleted',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.direct[111][42]).toBeDefined();
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId('direct', 42);
|
||||
expect(useMessageStore.getState().messages.direct[111]?.[42]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a broadcast message by messageId', () => {
|
||||
const message: Message = {
|
||||
type: 'broadcast',
|
||||
channel: 2,
|
||||
to: 0,
|
||||
from: 0,
|
||||
date: new Date().toISOString(),
|
||||
messageId: 77,
|
||||
state: 'waiting',
|
||||
message: 'Broadcast to delete',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.broadcast[2][77]).toBeDefined();
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId('broadcast', 77);
|
||||
expect(useMessageStore.getState().messages.broadcast[2]?.[77]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,25 +4,22 @@ import { produce } from 'immer';
|
||||
import { Types } from '@meshtastic/core';
|
||||
import { zustandIndexDBStorage } from "./storage/indexDB.ts";
|
||||
|
||||
const MESSAGE_STATES = {
|
||||
ack: "ack",
|
||||
waiting: "waiting",
|
||||
failed: 'failed',
|
||||
};
|
||||
export type MessageState = keyof typeof MESSAGE_STATES;
|
||||
export enum MessageState {
|
||||
Ack = "ack",
|
||||
Waiting = "waiting",
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
export const ChatTypes = {
|
||||
DIRECT: "direct",
|
||||
BROADCAST: "broadcast",
|
||||
} as const;
|
||||
|
||||
export type MessageType = "broadcast" | "direct";
|
||||
export enum MessageType {
|
||||
Direct = "direct",
|
||||
Broadcast = "broadcast",
|
||||
}
|
||||
|
||||
interface MessageBase {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: string;
|
||||
date: number; // Unix timestamp in milliseconds
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
@@ -32,7 +29,7 @@ interface GenericMessage<T extends MessageType> extends MessageBase {
|
||||
type: T;
|
||||
}
|
||||
|
||||
export type Message = GenericMessage<'direct'> | GenericMessage<'broadcast'>;
|
||||
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
|
||||
|
||||
export interface MessageStore {
|
||||
messages: {
|
||||
@@ -55,10 +52,10 @@ export interface MessageStore {
|
||||
messageId: number;
|
||||
newState?: MessageState;
|
||||
}) => void;
|
||||
clearMessages: () => void;
|
||||
getMessages: (type: MessageType, options: { myNodeNum?: number; otherNodeNum?: number; channel?: number }) => Message[];
|
||||
getDraft: (key: Types.Destination) => string;
|
||||
setDraft: (key: Types.Destination, message: string) => void;
|
||||
clearAllMessages: () => void;
|
||||
clearMessageByMessageId: (type: MessageType, messageId: number) => void;
|
||||
clearDraft: (key: Types.Destination) => void;
|
||||
|
||||
@@ -73,7 +70,7 @@ export const useMessageStore = create<MessageStore>()(
|
||||
},
|
||||
draft: new Map<number, string>(),
|
||||
activeChat: 0,
|
||||
chatType: 'broadcast',
|
||||
chatType: MessageType.Broadcast,
|
||||
nodeNum: 0,
|
||||
setNodeNum: (nodeNum) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
@@ -94,22 +91,32 @@ export const useMessageStore = create<MessageStore>()(
|
||||
saveMessage: (message) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
const group = state.messages[message.type];
|
||||
const key = message.type === 'direct' ? Number(message.to) : Number(message.channel);
|
||||
// Direct messages are keyed by the RECIPIENT's node number (`message.to`)
|
||||
// Broadcast messages are keyed by the channel number (`message.channel`)
|
||||
const key = message.type === MessageType.Direct ? Number(message.to) : Number(message.channel);
|
||||
if (!group[key]) {
|
||||
group[key] = {};
|
||||
}
|
||||
group[key][message.messageId] = message;
|
||||
const messageToSave = { ...message };
|
||||
group[key][message.messageId] = messageToSave;
|
||||
}));
|
||||
},
|
||||
setMessageState: ({ type, key, messageId, newState = 'ack' }) => {
|
||||
const group = get().messages[type];
|
||||
const messageMap = group[key];
|
||||
|
||||
if (!messageMap || !messageMap[messageId]) return;
|
||||
|
||||
set(produce((state: MessageStore) => {
|
||||
state.messages[type][key][messageId].state = newState;
|
||||
}));
|
||||
setMessageState: ({
|
||||
type,
|
||||
key,
|
||||
messageId,
|
||||
newState = MessageState.Ack,
|
||||
}) => {
|
||||
set(
|
||||
produce((state: MessageStore) => {
|
||||
const message = state.messages[type]?.[key]?.[messageId];
|
||||
if (message) {
|
||||
message.state = newState;
|
||||
} else {
|
||||
console.warn(`Message not found - type: ${type}, key: ${key}, messageId: ${messageId}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
clearMessages: () => {
|
||||
set(produce((state: MessageStore) => {
|
||||
@@ -120,24 +127,26 @@ export const useMessageStore = create<MessageStore>()(
|
||||
getMessages: (type, options) => {
|
||||
const state = get();
|
||||
|
||||
if (type === 'broadcast' && options.channel !== undefined) {
|
||||
if (type === MessageType.Broadcast && options.channel !== undefined) {
|
||||
const messageMap = state.messages.broadcast[options.channel] ?? {};
|
||||
return Object.values(messageMap).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
return Object.values(messageMap).sort((a, b) => a.date - b.date);
|
||||
}
|
||||
|
||||
if (type === 'direct' && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
|
||||
const receivedMap = state.messages.direct[options.otherNodeNum] ?? {};
|
||||
const sentMap = state.messages.direct[options.myNodeNum] ?? {};
|
||||
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
|
||||
// Messages TO the other node (sent by me) are keyed under their nodeNum
|
||||
const messagesToOtherNodeMap = state.messages.direct[options.otherNodeNum] ?? {};
|
||||
const sentByMe = Object.values(messagesToOtherNodeMap);
|
||||
|
||||
// Pull messages 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
|
||||
const receivedMessages = Object.values(receivedMap);
|
||||
// Messages TO me (potentially from the other node) are keyed under my nodeNum
|
||||
const messagesToMeMap = state.messages.direct[options.myNodeNum] ?? {};
|
||||
// Filter messages TO me to find the ones FROM the specific other node
|
||||
const sentByOtherNode = Object.values(messagesToMeMap).filter(
|
||||
(msg) => msg.from === options.otherNodeNum
|
||||
);
|
||||
|
||||
// Merge and sort chronologically
|
||||
return [...receivedMessages, ...sentMessages].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
return [...sentByMe, ...sentByOtherNode].sort(
|
||||
(a, b) => a.date - b.date
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,6 +179,12 @@ export const useMessageStore = create<MessageStore>()(
|
||||
state.draft.delete(key);
|
||||
}));
|
||||
},
|
||||
clearAllMessages: () => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.messages.direct = {};
|
||||
state.messages.broadcast = {};
|
||||
}));
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'meshtastic-message-store',
|
||||
|
||||
+12
-12
@@ -13,7 +13,7 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { ChatTypes, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
export const MessagesPage = () => {
|
||||
const { channels, nodes, hardware, hasNodeError } = useDevice();
|
||||
@@ -36,8 +36,8 @@ export const MessagesPage = () => {
|
||||
|
||||
const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
|
||||
|
||||
const isDirect = chatType === ChatTypes.DIRECT;
|
||||
const isBroadcast = chatType === ChatTypes.BROADCAST;
|
||||
const isDirect = chatType === MessageType.Direct;
|
||||
const isBroadcast = chatType === MessageType.Broadcast;
|
||||
|
||||
const currentChat = { type: chatType, id: activeChat };
|
||||
|
||||
@@ -54,7 +54,7 @@ export const MessagesPage = () => {
|
||||
? "Primary"
|
||||
: `Ch ${channel.index}`}
|
||||
onClick={() => {
|
||||
setChatType("broadcast");
|
||||
setChatType(MessageType.Broadcast);
|
||||
setActiveChat(channel.index);
|
||||
}}
|
||||
element={<HashIcon size={16} className="mr-2" />}
|
||||
@@ -79,7 +79,7 @@ export const MessagesPage = () => {
|
||||
`!${numberToHexUnpadded(otherNode.num)}`}
|
||||
active={activeChat === otherNode.num && chatType === "direct"}
|
||||
onClick={() => {
|
||||
setChatType("direct");
|
||||
setChatType(MessageType.Direct);
|
||||
setActiveChat(otherNode.num);
|
||||
}}
|
||||
element={
|
||||
@@ -98,13 +98,13 @@ export const MessagesPage = () => {
|
||||
<div className="flex flex-col w-full h-full container mx-auto">
|
||||
<PageLayout
|
||||
className="flex flex-col h-full"
|
||||
label={`Messages: ${chatType === "broadcast" && currentChannel
|
||||
label={`Messages: ${MessageType.Broadcast && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: chatType === "direct" && nodes.get(activeChat)
|
||||
: chatType === MessageType.Direct && nodes.get(activeChat)
|
||||
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
|
||||
: "Loading..."
|
||||
}`}
|
||||
actions={chatType === "direct"
|
||||
actions={chatType === MessageType.Direct
|
||||
? [
|
||||
{
|
||||
icon: nodes.get(activeChat)?.user?.publicKey.length
|
||||
@@ -131,7 +131,7 @@ export const MessagesPage = () => {
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChannelChat
|
||||
messages={getMessages('broadcast', {
|
||||
messages={getMessages(MessageType.Broadcast, {
|
||||
myNodeNum: getNodeNum(),
|
||||
channel: currentChannel?.index
|
||||
})}
|
||||
@@ -144,7 +144,7 @@ export const MessagesPage = () => {
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChannelChat
|
||||
messages={getMessages('direct', { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
|
||||
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,8 +153,8 @@ export const MessagesPage = () => {
|
||||
|
||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||
<MessageInput
|
||||
to={currentChat.type === ChatTypes.DIRECT ? activeChat : ChatTypes.BROADCAST}
|
||||
channel={currentChat.type === ChatTypes.DIRECT ? Types.ChannelNumber.Primary : currentChat.id}
|
||||
to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast}
|
||||
channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id}
|
||||
maxBytes={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user