fix: resolved issue with being unable to scroll up in the input field

This commit is contained in:
Dan Ditomaso
2025-03-06 14:09:47 -05:00
parent 39f26f475b
commit d379769672
15 changed files with 905 additions and 128 deletions
@@ -1,8 +1,8 @@
import { describe, it, vi, expect } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { TransportHTTP } from "@meshtastic/transport-http";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { vi, describe, it, expect } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
@@ -41,25 +41,27 @@ describe("HTTP Component", () => {
it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "meshtastic.local" } });
expect(input).toHaveValue("meshtastic.local");
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: 'meshtastic.local' } })
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument();
fireEvent.click(switchInput);
fireEvent.click(switchInput)
expect(screen.getByText("https://")).toBeInTheDocument();
fireEvent.click(switchInput);
fireEvent.click(switchInput)
expect(switchInput).not.toBeChecked();
expect(screen.getByText("http://")).toBeInTheDocument();
});
it("enables HTTPS toggle when location protocol is https", () => {
Object.defineProperty(globalThis, "location", {
Object.defineProperty(window, "location", {
value: { protocol: "https:" },
writable: true,
});
@@ -70,21 +72,19 @@ describe("HTTP Component", () => {
expect(switchInput).toBeChecked();
});
it.skip("submits form and triggers connection process", () => {
// This will need further work to test, as it involves a lot of other plumbing mocking
it("submits form and triggers connection process", async () => {
const closeDialog = vi.fn();
render(<HTTP closeDialog={closeDialog} />);
const button = screen.getByRole("button", { name: "Connect" });
expect(button).not.toBeDisabled();
fireEvent.click(button);
waitFor(() => {
await waitFor(() => {
expect(button).toBeDisabled();
expect(closeDialog).toHaveBeenCalled();
expect(TransportHTTP.create).toHaveBeenCalled();
expect(MeshDevice).toHaveBeenCalled();
expect(closeDialog).toBeCalled();
expect(TransportHTTP.create).toBeCalled();
expect(MeshDevice).toBeCalled();
});
});
});
@@ -1,14 +1,10 @@
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
import { Message } from "@components/PageComponents/Messages/Message.tsx";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import type { Types } from "@meshtastic/core";
import { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
export interface ChannelChatProps {
messages?: MessageWithState[];
channel: Types.ChannelNumber;
to: Types.Destination;
}
const EmptyState = () => (
@@ -20,8 +16,6 @@ const EmptyState = () => (
export const ChannelChat = ({
messages,
channel,
to,
}: ChannelChatProps) => {
const { nodes } = useDevice();
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -30,10 +24,12 @@ export const ChannelChat = ({
const scrollToBottom = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
const isNearBottom = scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
const isNearBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
100;
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
@@ -42,7 +38,7 @@ export const ChannelChat = ({
useEffect(() => {
scrollToBottom();
}, [scrollToBottom]);
}, [scrollToBottom, messages]);
if (!messages?.length) {
return (
@@ -50,34 +46,31 @@ export const ChannelChat = ({
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>
);
}
return (
<div className="flex flex-col h-full container mx-auto">
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
{messages.map((message, index) => {
return (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={index > 0 &&
messages[index - 1].from === message.from}
/>
);
})}
<div
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">
{messages.map((message, index) => (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={
index > 0 && messages[index - 1].from === message.from
}
/>
))}
<div ref={messagesEndRef} className="w-full" />
</div>
</div>
<div className="shrink-0 mt-2 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>
);
};
};
@@ -0,0 +1,148 @@
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';
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(),
}));
vi.mock("@core/utils/debounce.ts", () => ({
debounce: (fn: () => void) => fn,
}));
vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>
}));
vi.mock("@components/UI/Input.tsx", () => ({
Input: (props: any) => <input {...props} />
}));
vi.mock("lucide-react", () => ({
SendIcon: () => <div data-testid="send-icon">Send</div>
}));
// TODO: getting an error with this test
describe.skip('MessageInput Component', () => {
const mockProps = {
to: "broadcast" as const,
channel: 0 as const,
maxBytes: 100,
};
const mockSetMessageDraft = vi.fn();
const mockSetMessageState = vi.fn();
const mockSendText = vi.fn().mockResolvedValue(123); // mock ID returned from sendText
beforeEach(() => {
vi.clearAllMocks();
// Setup the mock implementation for useDevice
(useDevice as Mock).mockReturnValue({
connection: {
sendText: mockSendText,
},
setMessageState: mockSetMessageState,
messageDraft: "",
setMessageDraft: mockSetMessageDraft,
hardware: {
myNodeNum: 1234567890,
},
});
});
it('renders correctly with initial state', () => {
render(<MessageInput {...mockProps} />);
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
expect(screen.getByText('0/100')).toBeInTheDocument();
});
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('does not allow input exceeding max bytes', () => {
render(<MessageInput {...mockProps} maxBytes={5} />);
const inputField = screen.getByPlaceholderText('Enter Message');
expect(screen.getByText('5/5')).toBeInTheDocument();
fireEvent.change(inputField, { target: { value: 'Hello' } })
expect(inputField).toHaveValue('Hello');
expect(screen.getByText('5/5')).toBeInTheDocument();
});
it('sends message and resets form when submitting', async () => {
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'
);
});
expect(inputField).toHaveValue('');
expect(screen.getByText('0/100')).toBeInTheDocument();
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
});
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,
},
});
render(<MessageInput {...mockProps} />);
const inputField = screen.getByRole('label');
expect(inputField).toHaveValue('Existing draft');
expect(screen.getByText('14/100')).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { debounce } from "../../../core/utils/debounce.ts";
import { debounce } from "@core/utils/debounce.ts";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -22,6 +22,8 @@ export const MessageInput = ({
setMessageState,
messageDraft,
setMessageDraft,
isQueueingMessages,
queueStatus,
hardware,
} = useDevice();
const myNodeNum = hardware.myNodeNum;
@@ -33,8 +35,12 @@ export const MessageInput = ({
[setMessageDraft],
);
// sends the message to the selected destination
const sendText = useCallback(
async (message: string) => {
console.log('queueStatus', queueStatus.free)
await connection
?.sendText(message, to, true, channel)
.then((id: number) =>
@@ -58,7 +64,7 @@ export const MessageInput = ({
)
);
},
[channel, connection, myNodeNum, setMessageState, to],
[channel, connection, myNodeNum, setMessageState, to, queueStatus],
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -81,14 +87,17 @@ export const MessageInput = ({
if (localDraft === "") return;
const message = formData.get("messageInput") as string;
startTransition(() => {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
if (!isQueueingMessages) {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
}
});
}}
>
<div className="flex grow gap-2">
<div className="flex grow gap-2 ">
<span className="w-full">
<Input
autoFocus
@@ -99,11 +108,11 @@ export const MessageInput = ({
onChange={handleInputChange}
/>
</span>
<div className="flex items-center w-24 p-2 place-content-end">
<label className="flex items-center w-24 p-2 place-content-end">
{messageBytes}/{maxBytes}
</div>
</label>
<Button type="submit">
<Button type="submit" className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-slate-700">
<SendIcon size={16} />
</Button>
</div>
+3
View File
@@ -10,6 +10,7 @@ export interface PageLayoutProps {
label: string;
noPadding?: boolean;
children: React.ReactNode;
className?: string;
actions?: {
icon: LucideIcon;
iconClasses?: string;
@@ -23,6 +24,7 @@ export const PageLayout = ({
label,
noPadding,
actions,
className,
children,
}: PageLayoutProps) => {
return (
@@ -63,6 +65,7 @@ export const PageLayout = ({
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
className
)}
>
{children}
+1 -1
View File
@@ -66,7 +66,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
return showSidebar
? (
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700">
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-400">
<div className="flex justify-between px-8 pt-6">
<div>
<span className="text-lg font-medium">
+27 -5
View File
@@ -1,5 +1,5 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf, Types } from "@meshtastic/core";
import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
import { produce } from "immer";
import { createContext, useContext } from "react";
import { create as createStore } from "zustand";
@@ -28,6 +28,10 @@ export type DialogVariant =
| "pkiBackup"
| "nodeDetails";
type QueueStatus = {
res: number, free: number, maxlen: number
}
export interface Device {
id: number;
status: Types.DeviceStatusEnum;
@@ -47,13 +51,15 @@ export interface Device {
number,
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
>;
connection?: Types.ConnectionType;
connection?: MeshDevice;
activePage: Page;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
// currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean;
messageDraft: string;
queueStatus: QueueStatus,
isQueueingMessages: boolean,
dialog: {
import: boolean;
QR: boolean;
@@ -65,6 +71,7 @@ export interface Device {
nodeDetails: boolean;
};
setStatus: (status: Types.DeviceStatusEnum) => void;
setConfig: (config: Protobuf.Config.Config) => void;
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
@@ -80,7 +87,7 @@ export interface Device {
addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
addConnection: (connection: Types.ConnectionType) => void;
addConnection: (connection: MeshDevice) => void;
addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
@@ -98,6 +105,7 @@ export interface Device {
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void;
setQueueStatus: (status: QueueStatus) => void;
}
export interface DeviceState {
@@ -137,6 +145,10 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
activePage: "messages",
activeNode: 0,
waypoints: [],
queueStatus: {
res: 0, free: 0, maxlen: 0
},
isQueueingMessages: false,
dialog: {
import: false,
QR: false,
@@ -303,7 +315,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
.findIndex(
(wmc) =>
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
moduleConfig.payloadVariant.case,
);
if (workingModuleConfigIndex !== -1) {
device.workingModuleConfig[workingModuleConfigIndex] =
@@ -516,7 +528,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
set(
produce<DeviceState>((draft) => {
console.log("addTraceRoute called");
console.log(traceroute);
const device = draft.devices.get(id);
if (!device) {
return;
@@ -631,6 +642,17 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}),
);
},
setQueueStatus: (status: QueueStatus) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.queueStatus = status;
device.queueStatus.free >= 10 ? true : false
}
}),
);
}
});
}),
);
+14 -1
View File
@@ -71,6 +71,7 @@
}
@layer base {
*,
::after,
::before,
@@ -96,11 +97,23 @@
}
}
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
/* Prevent image dragging */
img {
-webkit-user-drag: none;
}
@keyframes spin-slower {
to {
transform: rotate(360deg);
@@ -109,4 +122,4 @@ img {
.animate-spin-slow {
animation: spin-slower 2s linear infinite;
}
}
+50 -33
View File
@@ -12,6 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
export const MessagesPage = () => {
const { channels, nodes, hardware, messages } = useDevice();
@@ -31,6 +32,11 @@ export const MessagesPage = () => {
const node = nodes.get(activeChat);
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
const messageDestination = chatType === "direct" ? activeChat : "broadcast";
const messageChannel = chatType === "direct"
? Types.ChannelNumber.Primary
: activeChat;
return (
<>
<Sidebar>
@@ -41,9 +47,9 @@ export const MessagesPage = () => {
label={channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? "Primary"
: `Ch ${channel.index}`}
active={activeChat === channel.index}
? "Primary"
: `Ch ${channel.index}`}
active={activeChat === channel.index && chatType === "broadcast"}
onClick={() => {
setChatType("broadcast");
setActiveChat(channel.index);
@@ -68,7 +74,7 @@ export const MessagesPage = () => {
key={node.num}
label={node.user?.longName ??
`!${numberToHexUnpadded(node.num)}`}
active={activeChat === node.num}
active={activeChat === node.num && chatType === "direct"}
onClick={() => {
setChatType("direct");
setActiveChat(node.num);
@@ -84,15 +90,15 @@ export const MessagesPage = () => {
</div>
</SidebarSection>
</Sidebar>
<div className="flex flex-col grow">
<div className="flex flex-col w-full h-full">
<PageLayout
label={`Messages: ${
chatType === "broadcast" && currentChannel
? getChannelName(currentChannel)
: chatType === "direct" && nodes.get(activeChat)
className="flex flex-col h-full"
label={`Messages: ${chatType === "broadcast" && currentChannel
? getChannelName(currentChannel)
: chatType === "direct" && nodes.get(activeChat)
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
: "Loading..."
}`}
}`}
actions={chatType === "direct"
? [
{
@@ -115,32 +121,43 @@ export const MessagesPage = () => {
]
: []}
>
{allChannels.map(
(channel) =>
activeChat === channel.index && (
<ChannelChat
key={channel.index}
to="broadcast"
messages={messages.broadcast.get(channel.index)}
channel={channel.index}
/>
),
)}
{filteredNodes.map(
(node) =>
activeChat === node.num && (
<ChannelChat
key={node.num}
to={activeChat}
messages={messages.direct.get(node.num)}
channel={Types.ChannelNumber.Primary}
/>
),
)}
<div className="flex-1 overflow-y-auto">
{chatType === "broadcast" && currentChannel && (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<ChannelChat
key={currentChannel.index}
to="broadcast"
messages={messages.broadcast.get(currentChannel.index)}
channel={currentChannel.index}
/>
</div>
</div>
)}
{chatType === "direct" && node && (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<ChannelChat
key={node.num}
messages={messages.direct.get(node.num)} channel={ChannelNumber.Primary} to={0} />
</div>
</div>
)}
</div>
{/* Single message input for both chat types */}
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput
to={messageDestination}
channel={messageChannel}
maxBytes={200}
/>
</div>
</PageLayout>
</div>
</>
);
};
export default MessagesPage;
export default MessagesPage;
+13 -3
View File
@@ -1,7 +1,17 @@
import "@testing-library/jest-dom";
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from "@testing-library/jest-dom/matchers";
globalThis.ResizeObserver = class {
class ResizeObserver {
observe() { }
unobserve() { }
disconnect() { }
};
}
globalThis.ResizeObserver = ResizeObserver;
expect.extend(matchers);
afterEach(() => {
cleanup();
});