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:
@@ -1,5 +1,5 @@
|
||||
import { useAppStore } from "../../../core/stores/appStore.ts";
|
||||
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
|
||||
import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
@@ -270,11 +270,6 @@ export const Security = () => {
|
||||
properties: {
|
||||
value: state.adminKey,
|
||||
showCopyButton: true,
|
||||
action: {
|
||||
icon: state.adminKeyVisible ? EyeOff : Eye,
|
||||
onClick: () =>
|
||||
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ import { subscribeAll } from "@core/subscriptions.ts";
|
||||
import { randId } from "@core/utils/randId.ts";
|
||||
import { BleConnection, ServiceUuid } from "@meshtastic/js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
|
||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||
@@ -43,7 +43,7 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
|
||||
{bleDevices.map((device) => (
|
||||
<Button
|
||||
key={device.id}
|
||||
className="dark:bg-slate-900 dark:text-white"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setConnectionInProgress(true);
|
||||
onConnect(device);
|
||||
@@ -57,8 +57,10 @@ export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) =
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="dark:bg-slate-900 dark:text-white"
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
|
||||
|
||||
await navigator.bluetooth
|
||||
.requestDevice({
|
||||
filters: [{ services: [ServiceUuid] }],
|
||||
|
||||
@@ -13,7 +13,7 @@ import { TransportHTTP } from "@meshtastic/transport-http";
|
||||
import { useState } from "react";
|
||||
import { useForm, useController } from "react-hook-form";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
interface FormData {
|
||||
ip: string;
|
||||
@@ -74,7 +74,7 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
|
||||
<Input
|
||||
prefix={tlsValue ? "https://" : "http://"}
|
||||
placeholder="000.000.000.000 / meshtastic.local"
|
||||
className="text-slate-900 dark:text-slate-900"
|
||||
className="text-slate-900 dark:text-slate-100"
|
||||
{...register("ip")}
|
||||
/>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@ export const HTTP = ({ closeDialog, setConnectionInProgress, connectionInProgres
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="dark:bg-slate-900 dark:text-white"
|
||||
variant={"default"}
|
||||
>
|
||||
<span>{connectionInProgress ? "Connecting..." : "Connect"}</span>
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { randId } from "@core/utils/randId.ts";
|
||||
import { MeshDevice } from "@meshtastic/core";
|
||||
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
|
||||
export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
|
||||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
||||
@@ -52,7 +52,7 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
|
||||
<Button
|
||||
key={`${usbVendorId ?? "UNK"}-${usbProductId ?? "UNK"}-${index}`}
|
||||
disabled={port.readable !== null}
|
||||
className="dark:bg-slate-900 dark:text-white"
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
setConnectionInProgress(true);
|
||||
await onConnect(port);
|
||||
@@ -68,7 +68,7 @@ export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="dark:bg-slate-900 dark:text-white"
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
await navigator.serial.requestPort().then((port) => {
|
||||
setSerialPorts(serialPorts.concat(port));
|
||||
|
||||
@@ -7,12 +7,7 @@ import { Mono } from "@components/generic/Mono.tsx";
|
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import type { Protobuf as ProtobufType } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import {
|
||||
BatteryChargingIcon,
|
||||
BatteryFullIcon,
|
||||
BatteryLowIcon,
|
||||
BatteryMediumIcon,
|
||||
Dot,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
@@ -28,7 +23,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts";
|
||||
import BatteryStatus from "@components/BatteryStatus.tsx";
|
||||
|
||||
export interface NodeDetailProps {
|
||||
node: ProtobufType.Mesh.NodeInfo;
|
||||
@@ -37,7 +33,7 @@ export interface NodeDetailProps {
|
||||
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
const { setChatType, setActiveChat } = useMessageStore();
|
||||
const { setActivePage } = useDevice();
|
||||
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
|
||||
const name = node.user?.longName ?? `UNK`;
|
||||
const shortName = node.user?.shortName ?? "UNK";
|
||||
const hwModel = node.user?.hwModel ?? 0;
|
||||
const hardwareType =
|
||||
@@ -50,10 +46,10 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dark:text-slate-900 p-1">
|
||||
<div className="p-1 text-slate-900">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Avatar text={shortName} />
|
||||
<Avatar text={shortName} size="sm" />
|
||||
|
||||
<div onFocusCapture={(e) => {
|
||||
// Required to prevent DM tooltip auto-appearing on creation
|
||||
@@ -113,24 +109,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
|
||||
|
||||
{!!node.deviceMetrics?.batteryLevel && (
|
||||
<div
|
||||
className="flex items-center gap-1 mt-0.5"
|
||||
title={`${node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
|
||||
} volts`}
|
||||
>
|
||||
{node.deviceMetrics?.batteryLevel > 100
|
||||
? <BatteryChargingIcon size={22} />
|
||||
: node.deviceMetrics?.batteryLevel > 80
|
||||
? <BatteryFullIcon size={22} />
|
||||
: node.deviceMetrics?.batteryLevel > 20
|
||||
? <BatteryMediumIcon size={22} />
|
||||
: <BatteryLowIcon size={22} />}
|
||||
<Subtle aria-label="Battery">
|
||||
{node.deviceMetrics?.batteryLevel > 100
|
||||
? "Charging"
|
||||
: `${node.deviceMetrics?.batteryLevel}%`}
|
||||
</Subtle>
|
||||
</div>
|
||||
<BatteryStatus deviceMetrics={node.deviceMetrics} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -198,7 +177,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
{!!node.deviceMetrics?.airUtilTx && (
|
||||
<div className="grow">
|
||||
<div>Airtime Util</div>
|
||||
<Mono>{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
|
||||
<Mono className="text-gray-500">{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -206,7 +185,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
{node.snr !== 0 && (
|
||||
<div className="mt-2">
|
||||
<div>SNR</div>
|
||||
<Mono className="flex items-center text-xs">
|
||||
<Mono className="flex items-center text-xs text-gray-500">
|
||||
{node.snr}db
|
||||
<Dot />
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user