add enums, improve tests, add styling

This commit is contained in:
Dan Ditomaso
2025-03-31 21:28:58 -04:00
parent 74db087d7d
commit a56ac84186
12 changed files with 606 additions and 395 deletions
@@ -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>
);
};