Remove duplicate node logic, UI Update, flatten build output (#586)

* refactor nodes to getNodes fn. ui updates

* fixed several styling issues

* fix: message specific styling/overflow

* added footer, fixed tests. styling

* fix: added theme support back to app component

* fix: hide emojis/reactions

* fix: added more padding to content element

* fix: fixed padding in content element

* updated color scheme

* fix: more dark mode styling improvements

* fix: padding and alignment fixes

* fix: prevent left sidebar collapse, added battery component

* fix: change scrollbars to "tiny" style, improved message scrolling, fixed bug with message input

* message store fixes, ui fixes

* fix: disabled message persistance until after release
This commit is contained in:
Dan Ditomaso
2025-05-02 09:00:24 -04:00
committed by GitHub
parent 3eafad7261
commit 99711fc44e
78 changed files with 2764 additions and 2028 deletions
@@ -1,72 +1,77 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import type { Message as MessageType } from "@core/stores/messageStore.ts";
import { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Message } from "@core/stores/messageStore/types.ts";
export interface ChannelChatProps {
messages?: MessageType[];
messages?: Message[];
}
const EmptyState = () => (
<div className="flex flex-col place-content-center place-items-center p-8 text-gray-500 dark:text-gray-400">
<InboxIcon className="h-8 w-8 mb-2" />
<div className="flex flex-1 flex-col place-content-center place-items-center p-8 text-slate-500 dark:text-slate-400">
<InboxIcon className="mb-2 h-8 w-8" />
<span className="text-sm">No Messages</span>
</div>
);
export const ChannelChat = ({
messages = [],
}: ChannelChatProps) => {
export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLUListElement>(null);
const userScrolledUpRef = useRef(false);
const scrollToBottom = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
const isNearBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
100;
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior });
});
}, []);
useEffect(() => {
scrollToBottom();
}, [scrollToBottom, messages]);
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
if (isScrolledToBottom || !userScrolledUpRef.current) {
scrollToBottom('smooth');
}
}, [messages, scrollToBottom]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
const handleScroll = () => {
if (!scrollContainer) return;
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
userScrolledUpRef.current = !isAtBottom;
};
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollContainer?.removeEventListener('scroll', handleScroll);
};
}, []);
if (!messages?.length) {
return (
<div className="flex flex-col h-full">
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
<div className="flex flex-1 flex-col items-center justify-center">
<EmptyState />
<div ref={messagesEndRef} />
</div>
);
}
return (
<div className="flex flex-col h-full">
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto py-4"
>
<div className="flex flex-col justify-end min-h-full space-y-4">
{messages?.map((message) => {
return (
<MessageItem
key={message?.messageId}
message={message}
/>
);
})}
<div ref={messagesEndRef} className="h-0 w-full" />
</div>
</div>
</div>
<ul
ref={scrollContainerRef}
className="flex flex-col flex-grow overflow-y-auto px-3 py-2"
>
<div className="flex-grow" />
{messages?.map((message) => (
<MessageItem
key={message.messageId ?? `${message.from}-${message.date}`}
message={message}
/>
))}
<div ref={messagesEndRef} className="h-px" />
</ul>
);
};
@@ -18,7 +18,7 @@ export const MessageActionsMenu = ({
onReply
}: MessageActionsMenuProps) => {
const hoverIconBarClass = cn(
"absolute top-2 right-4",
"absolute top-2 right-2",
"flex items-center gap-x-1",
"bg-white dark:bg-zinc-800",
"border border-gray-200 dark:border-zinc-600",
@@ -1,14 +1,11 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
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";
import { MessageInput, MessageInputProps } from './MessageInput.tsx';
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}>
Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => (
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}>
{children}
</button>
)),
@@ -23,16 +20,21 @@ vi.mock('@components/UI/Input.tsx', () => ({
placeholder={placeholder}
value={value}
onChange={onChange}
data-testid="message-input-field"
/>
)),
}));
vi.mock('@core/stores/deviceStore.ts', () => ({
useDevice: vi.fn(),
}));
const mockSetDraft = vi.fn();
const mockGetDraft = vi.fn();
const mockClearDraft = vi.fn();
vi.mock('@core/stores/messageStore.ts', () => ({
useMessageStore: vi.fn(),
vi.mock('@core/stores/messageStore', () => ({
useMessageStore: vi.fn(() => ({
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
})),
MessageState: {
Ack: 'ack',
Waiting: 'waiting',
@@ -44,111 +46,177 @@ vi.mock('@core/stores/messageStore.ts', () => ({
},
}));
vi.mock('@core/utils/debounce.ts', () => ({
debounce: vi.fn((fn) => fn),
}));
vi.mock('lucide-react', () => ({
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
}));
describe('MessageInput', () => {
const mockSetMessageState = vi.fn();
const mockSetActiveChat = vi.fn();
const mockSetDraft = vi.fn();
const mockGetDraft = vi.fn();
const mockClearDraft = vi.fn();
const mockSendText = vi.fn();
beforeEach(() => {
(useDevice as ReturnType<typeof vi.fn>).mockReturnValue({
connection: {
sendText: mockSendText,
},
});
(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);
});
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => {
render(<MessageInput {...props} />);
const mockOnSend = vi.fn();
const defaultProps: MessageInputProps = {
onSend: mockOnSend,
to: 123,
maxBytes: 256,
};
it.skip('sends text message and updates state to Ack on submit', async () => {
renderComponent({ to: 2, channel: 3, maxBytes: 256 });
beforeEach(() => {
vi.clearAllMocks();
mockGetDraft.mockReturnValue('');
});
const renderComponent = (props: Partial<MessageInputProps> = {}) => {
render(<MessageInput {...defaultProps} {...props} />);
};
it('should render the input field, byte counter, and send button', () => {
renderComponent();
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
expect(screen.getByTestId('byte-counter')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
});
it('should initialize with the draft from the store', () => {
const initialDraft = 'Existing draft message';
mockGetDraft.mockImplementation((key) => {
return key === defaultProps.to ? initialDraft : '';
});
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: 'Hello' } });
expect(inputElement.value).toBe(initialDraft);
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size;
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
});
it('should update input value, byte counter, and call setDraft on change within limits', () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const testMessage = 'Hello there!';
const expectedBytes = new Blob([testMessage]).size;
fireEvent.change(inputElement, { target: { value: testMessage } });
expect((inputElement as HTMLInputElement).value).toBe(testMessage);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
expect(mockSetDraft).toHaveBeenCalledTimes(1);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
});
it('should NOT update input value or call setDraft if maxBytes is exceeded', () => {
const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByPlaceholderText('Enter Message');
const initialValue = '12345';
const excessiveValue = '123456';
fireEvent.change(inputElement, { target: { value: initialValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue);
mockSetDraft.mockClear();
fireEvent.change(inputElement, { target: { value: excessiveValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`);
expect(mockSetDraft).not.toHaveBeenCalled();
});
it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const testMessage = 'Send this message';
fireEvent.change(inputElement, { target: { value: testMessage } });
fireEvent.submit(formElement);
await waitFor(() => {
expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3);
expect(mockSetMessageState).toHaveBeenCalledWith({
type: 'direct',
key: 123,
messageId: undefined,
newState: 'ack',
});
expect(mockClearDraft).toHaveBeenCalledWith(2);
expect(inputElement.value).toBe('');
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(testMessage);
expect((inputElement as HTMLInputElement).value).toBe('');
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`);
expect(mockClearDraft).toHaveBeenCalledTimes(1);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
});
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' } });
it('should trim whitespace before calling onSend', async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const testMessageWithWhitespace = ' Trim me! ';
const expectedTrimmedMessage = 'Trim me!';
fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } });
fireEvent.submit(formElement);
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(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
});
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' } });
it('should not call onSend or clearDraft if input is empty on submit', async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
expect((inputElement as HTMLInputElement).value).toBe('');
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');
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
});
it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => {
renderComponent();
const inputElement = screen.getByTestId('message-input-field');
const formElement = screen.getByRole('form');
const whitespaceMessage = ' \t ';
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
fireEvent.submit(formElement);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
});
it('should work with broadcast destination for drafts', () => {
const broadcastDest: Types.Destination = 'broadcast';
mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : '');
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft');
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const newMessage = 'New broadcast msg';
fireEvent.change(inputElement, { target: { value: newMessage } });
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
fireEvent.submit(formElement);
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
});
});
@@ -1,54 +1,28 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
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 { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
import { debounce } from "@core/utils/debounce.ts";
import { startTransition, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export interface MessageInputProps {
onSend: (message: string) => void;
to: Types.Destination;
channel: Types.ChannelNumber;
maxBytes: number;
}
export const MessageInput = ({
onSend,
to,
channel,
maxBytes,
}: MessageInputProps) => {
const { connection } = useDevice();
const { setMessageState, activeChat, setDraft, getDraft, clearDraft } = useMessageStore();
const [localDraft, setLocalDraft] = useState(getDraft(to));
const [messageBytes, setMessageBytes] = useState(0);
const debouncedSetMessageDraft = useMemo(
() => debounce((value: string) => setDraft(to, value), 300),
[setDraft, to]
);
const { setDraft, getDraft, clearDraft } = useMessageStore();
const calculateBytes = (text: string) => new Blob([text]).size;
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: MessageState.Ack });
}
// deno-lint-ignore no-explicit-any
} catch (e: any) {
setMessageState({
type: chatType,
key: activeChat,
messageId: e?.id,
newState: MessageState.Failed,
});
}
}, [channel, connection, setMessageState, to, activeChat, chatType]);
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft));
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
@@ -56,27 +30,28 @@ export const MessageInput = ({
if (byteLength <= maxBytes) {
setLocalDraft(newValue);
debouncedSetMessageDraft(newValue);
setMessageBytes(byteLength);
setDraft(to, newValue);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!localDraft.trim()) return;
// Reset bytes *before* sending (consider if onSend failure needs different handling)
setMessageBytes(0);
startTransition(() => {
sendText(localDraft.trim());
onSend(localDraft.trim());
setLocalDraft("");
clearDraft(to);
setMessageBytes(0);
});
};
return (
<div className="flex gap-2">
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-2">
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-1">
<label className="w-full">
<Input
autoFocus
@@ -88,13 +63,13 @@ export const MessageInput = ({
/>
</label>
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
<label data-testid="byte-counter" className="flex items-center w-20 p-1 text-sm place-content-end">
{messageBytes}/{maxBytes}
</label>
<Button
type="submit"
className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white"
variant="default"
>
<SendIcon size={16} />
</Button>
@@ -102,4 +77,4 @@ export const MessageInput = ({
</form>
</div>
);
};
};
@@ -5,146 +5,138 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { useDevice } 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, MessageState } from "@core/stores/messageStore.ts";
import { Protobuf } from "@meshtastic/js";
import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx";
import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts";
import { Protobuf, Types } from "@meshtastic/js";
import { Message } from "@core/stores/messageStore/types.ts";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
interface MessageProps {
message: Message;
// locale?: string; // locale
}
interface MessageStatus {
state: MessageState;
interface MessageStatusInfo {
displayText: string;
icon: LucideIcon;
ariaLabel: string;
iconClassName?: string;
}
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered" },
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message" },
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed" },
const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = {
[MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" },
[MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" },
[MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" },
};
const getMessageStatus = (state: MessageState): MessageStatus =>
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown" };
const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" };
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
const getMessageStatusInfo = (state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS;
const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
{status.displayText}
<TooltipArrow className="fill-gray-800" />
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{statusInfo.displayText}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const StatusIcon = ({ status, className }: { status: MessageStatus; className?: string }) => {
const Icon = status.icon;
const iconClass = cn("w-3.5 h-3.5 shrink-0", className);
return (
<StatusTooltip status={status}>
<span aria-label={status.ariaLabel} role="img">
<Icon className={iconClass} aria-hidden="true" />
</span>
</StatusTooltip>
);
};
interface MessageItemProps {
message: Message;
}
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
const _date = useMemo(() => new Date(date), [date]);
const locale = 'en-US'; // TODO: Make dynamic
const formattedTime = useMemo(() => _date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }), [_date, locale]);
const fullDate = useMemo(() => _date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }), [_date, locale]);
export const MessageItem = ({ message }: MessageItemProps) => {
const { getNode } = useDevice();
const { getMyNodeNum } = useMessageStore()
return (
<time dateTime={_date.toISOString()} className={cn("text-xs", className)}>
<span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDate}</span>
</time>
);
};
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]);
export const MessageItem = ({ message }: MessageProps) => {
const { getDevices } = useDeviceStore();
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
if (message?.from === null || message?.from === undefined) return null;
const devices = getDevices();
for (const device of devices) {
if (device.nodes.has(message.from)) {
return device.nodes.get(message.from) ?? null;
}
}
return null;
}, [getDevices, message.from]);
const { shortName, displayName } = useMemo(() => {
const fallbackName = message.from
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
const { displayName, shortName } = useMemo(() => {
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0');
const last4 = userIdHex.slice(-4);
const fallbackName = `Meshtastic ${last4}`
const longName = messageUser?.user?.longName;
const shortName = messageUser?.user?.shortName ?? fallbackName;
const displayName = longName || fallbackName;
return { shortName, displayName };
const derivedShortName = messageUser?.user?.shortName || fallbackName;
const derivedDisplayName = longName || derivedShortName;
return { displayName: derivedDisplayName, shortName: derivedShortName };
}, [messageUser, message.from]);
const messageStatus = getMessageStatus(message.state);
const messageText = message?.message ?? "";
const messageDate = message?.date;
const isFailed = message.state === MessageState.Failed;
const messageStatusInfo = getMessageStatusInfo(message.state);
const StatusIconComponent = messageStatusInfo.icon;
const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]);
const locale = 'en-US'; // TODO: Make dynamic via props or context
const formattedTime = useMemo(() =>
messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '',
[messageDate, locale]);
const fullDateTime = useMemo(() =>
messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '',
[messageDate, locale]);
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
const messageItemWrapperClass = cn(
"group w-full px-4 py-2 relative list-none",
"group w-full py-2 relative list-none",
"rounded-md",
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20",
"transition-colors duration-100 ease-in-out",
);
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
const avatarSizeClass = "size-11";
const gridGapClass = "gap-x-4";
const baseTextStyle = "text-sm text-gray-800 dark:text-gray-200";
const nameTextStyle = "font-medium text-gray-900 dark:text-gray-100 mr-2";
const dateTextStyle = "text-gray-500 dark:text-gray-400";
const statusIconBaseColor = "text-gray-400 dark:text-gray-500";
const statusIconFailedColor = "text-red-500 dark:text-red-400";
return (
<li className={messageItemWrapperClass}>
<div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}>
<Avatar size="sm" text={shortName} className={cn(avatarSizeClass, "pt-0.5")} />
<div className="grid grid-cols-[auto_1fr] gap-x-2">
<Avatar size="sm" text={shortName} className="pt-0.5" />
<div className="flex flex-col gap-1.5 min-w-0">
{messageDate != null ? (
<div className="flex items-center gap-1.5">
<span className={nameTextStyle} aria-hidden="true">
{displayName}
</span>
<TimeDisplay date={messageDate} className={dateTextStyle} />
<StatusIcon
status={messageStatus}
className={cn(isFailed ? statusIconFailedColor : statusIconBaseColor)}
/>
</div>
) : null}
<div className={cn(baseTextStyle, "whitespace-pre-wrap")}>
{messageText}
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate mr-1">
{displayName}
</span>
{messageDate && (
<time dateTime={messageDate.toISOString()} className={dateTextStyle}>
<span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDateTime}</span>
</time>
)}
{shouldShowStatusIcon && (
<StatusTooltip statusInfo={messageStatusInfo}>
<span aria-label={messageStatusInfo.ariaLabel} role="img">
<StatusIconComponent
className={cn("size-4 shrink-0", messageStatusInfo.iconClassName)}
aria-hidden="true"
/>
</span>
</StatusTooltip>
)}
</div>
{message?.message && (
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words">
{message.message}
</div>
)}
</div>
</div>
<MessageActionsMenu
onReply={() => console.log("Reply to message:", message.messageId)}
/>
{/* Actions Menu Placeholder */}
{/* <div className="absolute top-1 right-1">
<MessageActionsMenu onReply={() => console.log("Reply")} />
</div> */}
</li>
);
};
@@ -1,30 +1,33 @@
import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Protobuf } from "@meshtastic/core";
vi.mock("@core/stores/deviceStore");
describe("TraceRoute", () => {
const mockNodes = new Map([
const mockNodes = new Map<number, Protobuf.Mesh.NodeInfo>([
[
1,
{ num: 1, user: { longName: "Node A" } },
{ num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo,
],
[
2,
{ num: 2, user: { longName: "Node B" } },
{ num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo,
],
[
3,
{ num: 3, user: { longName: "Node C" } },
{ num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo,
],
]);
beforeEach(() => {
vi.resetAllMocks();
(useDevice as Mock).mockReturnValue({
nodes: mockNodes,
vi.mocked(useDevice).mockReturnValue({
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
});
});
@@ -38,17 +41,16 @@ describe("TraceRoute", () => {
/>
);
expect(screen.getByText("Route to destination:")).toBeInTheDocument();
expect(screen.getAllByText("Source Node")).toHaveLength(1);
expect(screen.getByText("Destination Node")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("Node B")).toBeInTheDocument();
expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops
expect(screen.getAllByText(/↓/)).toHaveLength(3);
expect(screen.getByText("↓ 10dB")).toBeInTheDocument();
expect(screen.getByText("↓ 20dB")).toBeInTheDocument();
expect(screen.getByText("↓ 30dB")).toBeInTheDocument();
expect(screen.getByText("Source Node")).toBeInTheDocument();
});
it("renders the route back when provided", () => {
@@ -64,9 +66,20 @@ describe("TraceRoute", () => {
);
expect(screen.getByText("Route back:")).toBeInTheDocument();
expect(screen.getAllByText("Source Node")).toHaveLength(2);
expect(screen.getAllByText("Destination Node")).toHaveLength(2);
expect(screen.getByText("Node C")).toBeInTheDocument();
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getByText("↓ 35dB")).toBeInTheDocument();
expect(screen.getByText("↓ 45dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
expect(screen.getByText("↓ 25dB")).toBeInTheDocument();
});
it("renders '??' for missing SNR values", () => {
@@ -78,18 +91,22 @@ describe("TraceRoute", () => {
/>
);
expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0);
expect(screen.getByText("Node A")).toBeInTheDocument();
expect(screen.getAllByText("↓ ??dB")).toHaveLength(2);
});
it("renders hop hex if node is not found", () => {
render(
<TraceRoute
from={{ user: { longName: "Source" } } as any}
to={{ user: { longName: "Dest" } } as any}
from={{ user: { longName: "Source" } } as unknown}
to={{ user: { longName: "Dest" } } as unknown}
route={[99]}
snrTowards={[5, 15]}
/>
);
expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex
expect(screen.getByText(/^!63$/)).toBeInTheDocument();
expect(screen.getByText("↓ 5dB")).toBeInTheDocument();
expect(screen.getByText("↓ 15dB")).toBeInTheDocument();
});
});
});
@@ -20,16 +20,16 @@ interface RoutePathProps {
}
const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => {
const { nodes } = useDevice();
const { getNode } = useDevice();
return (
<span className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
<span id={title} className="ml-4 border-l-2 border-l-background-primary pl-2 text-slate-900 dark:text-slate-900">
<p className="font-semibold">{title}</p>
<p>{startNode?.user?.longName}</p>
<p>↓ {snr?.[0] ?? "??"}dB</p>
{path.map((hop, i) => (
<span key={nodes.get(hop)?.num ?? hop}>
<p>{nodes.get(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p>
<span key={getNode(hop)?.num ?? hop}>
<p>{getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`}</p>
<p>↓ {snr?.[i + 1] ?? "??"}dB</p>
</span>
))}