Merge branch 'master' into unread-counts
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { Avatar } from "./UI/Avatar.tsx";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
FactoryIcon,
|
||||
LayersIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
MessageSquareIcon,
|
||||
PlusIcon,
|
||||
@@ -29,9 +27,13 @@ import {
|
||||
SmartphoneIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
XCircleIcon,
|
||||
Pin,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||
|
||||
export interface Group {
|
||||
label: string;
|
||||
@@ -45,7 +47,6 @@ export interface Command {
|
||||
subItems?: SubItem[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface SubItem {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -57,11 +58,10 @@ export const CommandPalette = () => {
|
||||
commandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
setSelectedDevice,
|
||||
removeDevice,
|
||||
selectedDevice,
|
||||
} = useAppStore();
|
||||
const { getDevices } = useDeviceStore();
|
||||
const { setDialogOpen, setActivePage, connection } = useDevice();
|
||||
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
|
||||
|
||||
const groups: Group[] = [
|
||||
{
|
||||
@@ -113,22 +113,22 @@ export const CommandPalette = () => {
|
||||
{
|
||||
label: "Switch Node",
|
||||
icon: ArrowLeftRightIcon,
|
||||
subItems: getDevices().map((device) => {
|
||||
return {
|
||||
label:
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Avatar
|
||||
text={device.nodes.get(device.hardware.myNodeNum)?.user
|
||||
?.shortName ?? device.hardware.myNodeNum.toString()}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setSelectedDevice(device.id);
|
||||
},
|
||||
};
|
||||
}),
|
||||
subItems: getDevices().map((device) => ({
|
||||
label:
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||
device.hardware.myNodeNum.toString(),
|
||||
icon: (
|
||||
<Avatar
|
||||
text={
|
||||
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
|
||||
device.hardware.myNodeNum.toString()
|
||||
}
|
||||
/>
|
||||
),
|
||||
action() {
|
||||
setSelectedDevice(device.id);
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: "Connect New Node",
|
||||
@@ -163,15 +163,6 @@ export const CommandPalette = () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Disconnect",
|
||||
icon: XCircleIcon,
|
||||
action() {
|
||||
void connection?.disconnect();
|
||||
setSelectedDevice(0);
|
||||
removeDevice(selectedDevice ?? 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Schedule Shutdown",
|
||||
icon: PowerIcon,
|
||||
@@ -186,6 +177,13 @@ export const CommandPalette = () => {
|
||||
setDialogOpen("reboot", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reboot To OTA Mode",
|
||||
icon: RefreshCwIcon,
|
||||
action() {
|
||||
setDialogOpen("rebootOTA", true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reset Nodes",
|
||||
icon: TrashIcon,
|
||||
@@ -221,16 +219,22 @@ export const CommandPalette = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "[WIP] Clear Messages",
|
||||
label: "Clear All Stored Message",
|
||||
icon: EraserIcon,
|
||||
action() {
|
||||
alert("This feature is not implemented");
|
||||
setDialogOpen("clearMessages", true);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const sortedGroups = [...groups].sort((a, b) => {
|
||||
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
|
||||
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
|
||||
return bPinned - aPinned;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
@@ -244,15 +248,45 @@ export const CommandPalette = () => {
|
||||
}, [setCommandPaletteOpen]);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
>
|
||||
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{groups.map((group) => (
|
||||
<CommandGroup key={group.label} heading={group.label}>
|
||||
{sortedGroups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.label}
|
||||
heading={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{group.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePinnedItem(group.label)}
|
||||
className={cn(
|
||||
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
|
||||
)}
|
||||
aria-description={
|
||||
pinnedItems.includes(group.label)
|
||||
? "Unpin command group"
|
||||
: "Pin command group"
|
||||
}
|
||||
>
|
||||
<span
|
||||
data-label
|
||||
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg"
|
||||
/>
|
||||
<Pin
|
||||
size={16}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
pinnedItems.includes(group.label)
|
||||
? "opacity-100 text-red-500"
|
||||
: "opacity-40 hover:opacity-70"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{group.commands.map((command) => (
|
||||
<div key={command.label}>
|
||||
<CommandItem
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx";
|
||||
|
||||
vi.mock('@core/stores/messageStore.ts', () => ({
|
||||
useMessageStore: vi.fn(() => ({
|
||||
clearAllMessages: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('ClearMessagesDialog', () => {
|
||||
const mockOnOpenChange = vi.fn();
|
||||
const mockClearAllMessages = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useMessageStore).mockReturnValue({ clearAllMessages: mockClearAllMessages });
|
||||
mockOnOpenChange.mockClear();
|
||||
mockClearAllMessages.mockClear();
|
||||
});
|
||||
|
||||
it('renders the dialog when open is true', () => {
|
||||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
expect(screen.getByText('Clear All Messages')).toBeVisible();
|
||||
expect(screen.getByText(/This action will clear all message history./)).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render the dialog when open is false', () => {
|
||||
render(<ClearMessagesDialog open={false} onOpenChange={mockOnOpenChange} />);
|
||||
expect(screen.queryByText('Clear All Messages')).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onOpenChange with false when the close button is clicked', () => {
|
||||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('calls onOpenChange with false when the dismiss button is clicked', () => {
|
||||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('calls clearAllMessages and onOpenChange with false when the clear messages button is clicked', () => {
|
||||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' }));
|
||||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
export interface ClearMessagesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ClearMessagesDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ClearMessagesDialogProps) => {
|
||||
const { clearAllMessages } = useMessageStore();
|
||||
const handleCloseDialog = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose onClick={handleCloseDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
||||
Clear All Messages
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action will clear all message history. This cannot be undone.
|
||||
Are you sure you want to continue?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCloseDialog}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
clearAllMessages();
|
||||
handleCloseDialog();
|
||||
}}
|
||||
>
|
||||
Clear Messages
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,9 @@ import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
|
||||
import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx";
|
||||
|
||||
|
||||
export const DialogManager = () => {
|
||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||
@@ -77,6 +80,18 @@ export const DialogManager = () => {
|
||||
setDialogOpen("refreshKeys", open);
|
||||
}}
|
||||
/>
|
||||
<RebootOTADialog
|
||||
open={dialog.rebootOTA}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("rebootOTA", open);
|
||||
}}
|
||||
/>
|
||||
<ClearMessagesDialog
|
||||
open={dialog.clearMessages}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen("clearMessages", open);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "../UI/Button.tsx";
|
||||
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
export interface NodeOptionsDialogProps {
|
||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||
@@ -29,23 +30,23 @@ export const NodeOptionsDialog = ({
|
||||
const {
|
||||
setNodeNumToBeRemoved,
|
||||
setNodeNumDetails,
|
||||
setChatType,
|
||||
setActiveChat,
|
||||
} = useAppStore();
|
||||
const { setChatType, setActiveChat } = useMessageStore();
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const longName = node?.user?.longName ??
|
||||
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
|
||||
const shortName = node?.user?.shortName ??
|
||||
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
|
||||
|
||||
function handleDirectMessage() {
|
||||
if (!node) return;
|
||||
setChatType("direct");
|
||||
setChatType(MessageType.Direct);
|
||||
setActiveChat(node.num);
|
||||
setActivePage("messages");
|
||||
}
|
||||
|
||||
function handleRequestPosition() {
|
||||
if (!node) return;
|
||||
toast({
|
||||
title: "Requesting position, please wait...",
|
||||
});
|
||||
@@ -58,7 +59,6 @@ export const NodeOptionsDialog = ({
|
||||
}
|
||||
|
||||
function handleTraceroute() {
|
||||
if (!node) return;
|
||||
toast({
|
||||
title: "Sending Traceroute, please wait...",
|
||||
});
|
||||
@@ -92,7 +92,7 @@ export const NodeOptionsDialog = ({
|
||||
key="remove"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setNodeNumToBeRemoved(node.num);
|
||||
setNodeNumToBeRemoved(node?.num);
|
||||
setDialogOpen("nodeRemoval", true);
|
||||
}}
|
||||
>
|
||||
@@ -103,7 +103,7 @@ export const NodeOptionsDialog = ({
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNodeNumDetails(node.num);
|
||||
setNodeNumDetails(node?.num);
|
||||
setDialogOpen("nodeDetails", true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RebootOTADialog } from './RebootOTADialog.tsx';
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const rebootOtaMock = vi.fn();
|
||||
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
||||
rebootOta: rebootOtaMock,
|
||||
};
|
||||
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
useDevice: () => ({
|
||||
connection: mockConnection,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@components/UI/Button.tsx', async () => {
|
||||
const actual = await vi.importActual('@components/UI/Button.tsx');
|
||||
return {
|
||||
...actual,
|
||||
Button: (props: any) => <button {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@components/UI/Input.tsx', async () => {
|
||||
const actual = await vi.importActual('@components/UI/Input.tsx');
|
||||
return {
|
||||
...actual,
|
||||
Input: (props: any) => <input {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@components/UI/Dialog.tsx', () => {
|
||||
return {
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogClose: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
describe('RebootOTADialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
rebootOtaMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders dialog with default input value', () => {
|
||||
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
|
||||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('schedules a reboot with delay and calls rebootOta', async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||
target: { value: '3' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
|
||||
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).toHaveBeenCalledWith(0);
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers an instant reboot', async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).toHaveBeenCalledWith(5);
|
||||
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call reboot if connection is undefined', async () => {
|
||||
const onOpenChangeMock = vi.fn();
|
||||
|
||||
// simulate no connection
|
||||
mockConnection = undefined;
|
||||
|
||||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||
|
||||
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rebootOtaMock).not.toHaveBeenCalled();
|
||||
expect(onOpenChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// reset connection for other tests
|
||||
mockConnection = { rebootOta: rebootOtaMock };
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
export interface RebootOTADialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||
|
||||
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
|
||||
const { connection } = useDevice();
|
||||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||
const [isScheduled, setIsScheduled] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
|
||||
|
||||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.validity.valid) {
|
||||
e.preventDefault();
|
||||
return
|
||||
};
|
||||
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
|
||||
const parsed = Number(val);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setTime(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRebootWithTimeout = async () => {
|
||||
if (!connection) return;
|
||||
setIsScheduled(true);
|
||||
|
||||
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log("Rebooting...");
|
||||
resolve();
|
||||
}, delay * 1000);
|
||||
}).finally(() => {
|
||||
setIsScheduled(false);
|
||||
onOpenChange(false);
|
||||
setInputValue(DEFAULT_REBOOT_DELAY.toString());
|
||||
});
|
||||
connection.rebootOta(0);
|
||||
};
|
||||
|
||||
const handleInstantReboot = async () => {
|
||||
if (!connection) return;
|
||||
|
||||
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2 p-2 items-center relative">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={86400}
|
||||
className="dark:text-slate-900 appearance-none"
|
||||
value={inputValue}
|
||||
onChange={handleSetTime}
|
||||
placeholder="Enter delay (sec)"
|
||||
/>
|
||||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
||||
<ClockIcon className="mr-2" size={18} />
|
||||
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="destructive" onClick={() => handleInstantReboot()}>
|
||||
<RefreshCwIcon className="mr-2" size={16} />
|
||||
Reboot to OTA Mode Now
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog";
|
||||
import { eventBus } from "@core/utils/eventBus";
|
||||
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
|
||||
vi.mock('@core/utils/eventBus', () => ({
|
||||
eventBus: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
import { useController, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "text" | "number" | "password";
|
||||
@@ -17,6 +17,12 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
step?: number;
|
||||
fieldLength?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
currentValueLength?: number;
|
||||
showCharacterCount?: boolean;
|
||||
},
|
||||
action?: {
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
@@ -29,42 +35,59 @@ export function GenericInput<T extends FieldValues>({
|
||||
disabled,
|
||||
field,
|
||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||
const { fieldLength, ...restProperties } = field.properties || {};
|
||||
|
||||
const [passwordShown, setPasswordShown] = useState(false);
|
||||
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0);
|
||||
|
||||
const { field: controllerField } = useController({
|
||||
name: field.name,
|
||||
control,
|
||||
});
|
||||
|
||||
const togglePasswordVisiblity = () => {
|
||||
setPasswordShown(!passwordShown);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (field.properties?.fieldLength?.max && newValue.length > field.properties?.fieldLength?.max) {
|
||||
return;
|
||||
}
|
||||
setCurrentLength(newValue.length);
|
||||
|
||||
if (field.inputChange) field.inputChange(e);
|
||||
|
||||
controllerField.onChange(field.type === "number" ? Number.parseFloat(newValue).toString() : newValue);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...rest } }) => (
|
||||
<Input
|
||||
type={field.type === "password" && passwordShown
|
||||
? "text"
|
||||
: field.type}
|
||||
action={field.type === "password"
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type={field.type === "password" && passwordShown ? "text" : field.type}
|
||||
action={
|
||||
field.type === "password"
|
||||
? {
|
||||
icon: passwordShown ? EyeOff : Eye,
|
||||
onClick: togglePasswordVisiblity,
|
||||
}
|
||||
: undefined}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number" ? Number.parseFloat(value) : value}
|
||||
id={field.name}
|
||||
onChange={(e) => {
|
||||
if (field.inputChange) field.inputChange(e);
|
||||
onChange(
|
||||
field.type === "number"
|
||||
? Number.parseFloat(e.target.value)
|
||||
: e.target.value,
|
||||
);
|
||||
}}
|
||||
{...field.properties}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
/>
|
||||
: undefined
|
||||
}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
|
||||
id={field.name}
|
||||
onChange={handleInputChange}
|
||||
{...restProperties}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
SelectValue,
|
||||
} from "@components/UI/Select.tsx";
|
||||
import { useController, type FieldValues } from "react-hook-form";
|
||||
import { computeHeadingLevel } from "@core/utils/test.tsx";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select";
|
||||
selectChange?: (e: string, name: string) => void;
|
||||
validate?: (newValue: string) => Promise<boolean>;
|
||||
defaultValue?: string;
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
defaultValue?: T;
|
||||
enumValue: {
|
||||
[s: string]: string | number;
|
||||
};
|
||||
@@ -70,7 +71,6 @@ export function SelectInput<T extends FieldValues>({
|
||||
onChange(Number.parseInt(newValue));
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Select
|
||||
onValueChange={handleValueChange}
|
||||
|
||||
@@ -5,15 +5,10 @@ export const KeyBackupReminder = () => {
|
||||
const { setDialogOpen } = useDevice();
|
||||
|
||||
useBackupReminder({
|
||||
reminderInDays: 7,
|
||||
message:
|
||||
"We recommend backing up your key data regularly. Would you like to back up now?",
|
||||
onAccept: () => setDialogOpen("pkiBackup", true),
|
||||
enabled: true,
|
||||
cookieOptions: {
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
},
|
||||
});
|
||||
// deno-lint-ignore jsx-no-useless-fragment
|
||||
return <></>;
|
||||
|
||||
@@ -34,9 +34,10 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
||||
settings: {
|
||||
...data.settings,
|
||||
psk: toByteArray(pass),
|
||||
moduleSettings: {...data.settings.moduleSettings,
|
||||
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
|
||||
...data.settings.moduleSettings,
|
||||
positionPrecision: data.settings.moduleSettings.positionPrecision,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
connection?.setChannel(channel).then(() => {
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock('@core/stores/deviceStore', () => ({
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
useDevice: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
|
||||
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({
|
||||
useUnsafeRolesDialog: vi.fn()
|
||||
}));
|
||||
|
||||
|
||||
@@ -82,6 +82,19 @@ export const Device = () => {
|
||||
label: "Disable Triple Click",
|
||||
description: "Disable triple click",
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'tzdef',
|
||||
label: 'POSIX Timezone',
|
||||
description: 'The POSIX timezone string for the device',
|
||||
properties: {
|
||||
fieldLength: {
|
||||
max: 64,
|
||||
currentValueLength: config.device?.tzdef?.length,
|
||||
showCharacterCount: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "ledHeartbeatDisabled",
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
// import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
// import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
||||
// import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
// import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
// vi.mock('@core/stores/deviceStore', () => ({
|
||||
// useDevice: vi.fn()
|
||||
// }));
|
||||
|
||||
// vi.mock('@components/Form/DynamicForm', () => ({
|
||||
// DynamicForm: vi.fn(({ onSubmit }) => {
|
||||
// return (
|
||||
// <div data-testid="dynamic-form">
|
||||
// <select
|
||||
// data-testid="role-select"
|
||||
// onChange={(e) => {
|
||||
// const mockData = { role: e.target.value };
|
||||
// onSubmit(mockData);
|
||||
// }}
|
||||
// >
|
||||
// {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
|
||||
// <option key={key} value={value}>
|
||||
// {key}
|
||||
// </option>
|
||||
// ))}
|
||||
// </select>
|
||||
// <button type="submit"
|
||||
// data-testid="submit-button"
|
||||
// onClick={() => onSubmit({ role: "CLIENT" })}
|
||||
// >
|
||||
// Submit
|
||||
// </button>
|
||||
// </div>
|
||||
// );
|
||||
// })
|
||||
// }));
|
||||
|
||||
// describe('Network component', () => {
|
||||
// const setWorkingConfigMock = vi.fn();
|
||||
// const mockDeviceConfig = {
|
||||
// role: "CLIENT",
|
||||
// buttonGpio: 0,
|
||||
// buzzerGpio: 0,
|
||||
// rebroadcastMode: "ALL",
|
||||
// nodeInfoBroadcastSecs: 300,
|
||||
// doubleTapAsButtonPress: false,
|
||||
// disableTripleClick: false,
|
||||
// ledHeartbeatDisabled: false,
|
||||
// };
|
||||
|
||||
// beforeEach(() => {
|
||||
// vi.resetAllMocks();
|
||||
|
||||
// (useDevice as any).mockReturnValue({
|
||||
// config: {
|
||||
// device: mockDeviceConfig
|
||||
// },
|
||||
// setWorkingConfig: setWorkingConfigMock
|
||||
// });
|
||||
|
||||
// });
|
||||
|
||||
// afterEach(() => {
|
||||
// vi.clearAllMocks();
|
||||
// });
|
||||
|
||||
// it('should render the Network form', () => {
|
||||
// render(<Network />);
|
||||
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
||||
// });
|
||||
|
||||
// it('should call setWorkingConfig when form is submitted', async () => {
|
||||
// render(<Network />);
|
||||
|
||||
// fireEvent.click(screen.getByTestId('submit-button'));
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||
// expect.objectContaining({
|
||||
// payloadVariant: {
|
||||
// case: "device",
|
||||
// value: expect.objectContaining({ role: "CLIENT" })
|
||||
// }
|
||||
// })
|
||||
// );
|
||||
// });
|
||||
// });
|
||||
|
||||
|
||||
// it('should create config with proper structure', async () => {
|
||||
// render(<Network />);
|
||||
|
||||
// // Simulate form submission
|
||||
// fireEvent.click(screen.getByTestId('submit-button'));
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||
// expect.objectContaining({
|
||||
// payloadVariant: {
|
||||
// case: "network",
|
||||
// value: expect.any(Object)
|
||||
// }
|
||||
// })
|
||||
// );
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
|
||||
vi.mock('@core/stores/deviceStore', () => ({
|
||||
useDevice: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@components/Form/DynamicForm', async () => {
|
||||
const React = await import('react');
|
||||
const { useState } = React;
|
||||
|
||||
return {
|
||||
DynamicForm: ({ onSubmit, defaultValues }: any) => {
|
||||
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false);
|
||||
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? '');
|
||||
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? '');
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({
|
||||
...defaultValues,
|
||||
wifiEnabled,
|
||||
wifiSsid: ssid,
|
||||
wifiPsk: psk,
|
||||
});
|
||||
}}
|
||||
data-testid="dynamic-form"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="WiFi Enabled"
|
||||
checked={wifiEnabled}
|
||||
onChange={(e) => setWifiEnabled(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
aria-label="SSID"
|
||||
value={ssid}
|
||||
onChange={(e) => setSsid(e.target.value)}
|
||||
disabled={!wifiEnabled}
|
||||
/>
|
||||
<input
|
||||
aria-label="PSK"
|
||||
value={psk}
|
||||
onChange={(e) => setPsk(e.target.value)}
|
||||
disabled={!wifiEnabled}
|
||||
/>
|
||||
<button type="submit" data-testid="submit-button">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
;
|
||||
|
||||
describe('Network component', () => {
|
||||
const setWorkingConfigMock = vi.fn();
|
||||
const mockNetworkConfig = {
|
||||
wifiEnabled: false,
|
||||
wifiSsid: '',
|
||||
wifiPsk: '',
|
||||
ntpServer: '',
|
||||
ethEnabled: false,
|
||||
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
||||
ipv4Config: {
|
||||
ip: 0,
|
||||
gateway: 0,
|
||||
subnet: 0,
|
||||
dns: 0,
|
||||
},
|
||||
enabledProtocols:
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||
rsyslogServer: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
(useDevice as any).mockReturnValue({
|
||||
config: {
|
||||
network: mockNetworkConfig
|
||||
},
|
||||
setWorkingConfig: setWorkingConfigMock
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the Network form', () => {
|
||||
render(<Network />);
|
||||
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable SSID and PSK fields when wifi is off', () => {
|
||||
render(<Network />);
|
||||
expect(screen.getByLabelText("SSID")).toBeDisabled();
|
||||
expect(screen.getByLabelText("PSK")).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable SSID and PSK when wifi is toggled on', async () => {
|
||||
render(<Network />);
|
||||
const toggle = screen.getByLabelText("WiFi Enabled");
|
||||
screen.debug()
|
||||
|
||||
fireEvent.click(toggle); // turns wifiEnabled = true
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("SSID")).not.toBeDisabled();
|
||||
expect(screen.getByLabelText("PSK")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call setWorkingConfig with the right structure on submit', async () => {
|
||||
render(<Network />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("submit-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloadVariant: {
|
||||
case: "network",
|
||||
value: expect.objectContaining({
|
||||
wifiEnabled: false,
|
||||
wifiSsid: '',
|
||||
wifiPsk: '',
|
||||
ntpServer: '',
|
||||
ethEnabled: false,
|
||||
rsyslogServer: '',
|
||||
})
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => {
|
||||
render(<Network />);
|
||||
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("SSID"), {
|
||||
target: { value: "MySSID" }
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("PSK"), {
|
||||
target: { value: "MySecretPSK" }
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("submit-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloadVariant: {
|
||||
case: "network",
|
||||
value: expect.objectContaining({
|
||||
wifiEnabled: true,
|
||||
wifiSsid: "MySSID",
|
||||
wifiPsk: "MySecretPSK"
|
||||
})
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+30
-5
@@ -1,4 +1,4 @@
|
||||
import type { NetworkValidation } from "@app/validation/config/network.tsx";
|
||||
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
@@ -7,11 +7,18 @@ import {
|
||||
convertIpAddressToInt,
|
||||
} from "@core/utils/ip.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { validateSchema } from "@app/validation/validate.ts";
|
||||
|
||||
export const Network = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
|
||||
const onSubmit = (data: NetworkValidation) => {
|
||||
const result = validateSchema(NetworkValidationSchema, data);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Validation errors:", result.errors);
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
@@ -21,10 +28,10 @@ export const Network = () => {
|
||||
ipv4Config: create(
|
||||
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
|
||||
{
|
||||
ip: convertIpAddressToInt(data.ipv4Config.ip) ?? 0,
|
||||
gateway: convertIpAddressToInt(data.ipv4Config.gateway) ?? 0,
|
||||
subnet: convertIpAddressToInt(data.ipv4Config.subnet) ?? 0,
|
||||
dns: convertIpAddressToInt(data.ipv4Config.dns) ?? 0,
|
||||
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
|
||||
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
|
||||
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
|
||||
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -48,6 +55,8 @@ export const Network = () => {
|
||||
),
|
||||
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
||||
},
|
||||
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST
|
||||
|
||||
}}
|
||||
fieldGroups={[
|
||||
{
|
||||
@@ -165,6 +174,22 @@ export const Network = () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "UDP Config",
|
||||
description: "UDP over Mesh configuration",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "enabledProtocols",
|
||||
label: "Mesh via UDP",
|
||||
properties: {
|
||||
enumValue:
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||
formatEnumName: true,
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "NTP Config",
|
||||
description: "NTP configuration",
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type FlagName,
|
||||
usePositionFlags,
|
||||
} from "../../../core/hooks/usePositionFlags.ts";
|
||||
import type { PositionValidation } from "@app/validation/config/position.tsx";
|
||||
} from "@core/hooks/usePositionFlags.ts";
|
||||
import type { PositionValidation } from "@app/validation/config/position.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
@@ -12,7 +12,7 @@ import { useCallback } from "react";
|
||||
export const Position = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||
config?.position.positionFlags ?? 0,
|
||||
config?.position?.positionFlags ?? 0,
|
||||
);
|
||||
|
||||
const onSubmit = (data: PositionValidation) => {
|
||||
@@ -74,7 +74,7 @@ export const Position = () => {
|
||||
name: "positionFlags",
|
||||
value: activeFlags,
|
||||
isChecked: (name: string) =>
|
||||
activeFlags.includes(name as FlagName),
|
||||
activeFlags?.includes(name as FlagName) ?? false,
|
||||
onValueChange: onPositonFlagChange,
|
||||
label: "Position Flags",
|
||||
placeholder: "Select position flags...",
|
||||
|
||||
@@ -7,10 +7,12 @@ 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";
|
||||
|
||||
export const BLE = ({ closeDialog }: TabElementProps) => {
|
||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const messageStore = useMessageStore()
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
|
||||
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
||||
@@ -30,7 +32,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
|
||||
device: bleDevice,
|
||||
});
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection);
|
||||
subscribeAll(device, connection, messageStore);
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Input } from "@components/UI/Input.tsx";
|
||||
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||
import { Label } from "@components/UI/Label.tsx";
|
||||
import { Switch } from "@components/UI/Switch.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
@@ -11,6 +12,8 @@ import { MeshDevice } from "@meshtastic/core";
|
||||
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";
|
||||
|
||||
interface FormData {
|
||||
ip: string;
|
||||
@@ -21,6 +24,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
||||
const isURLHTTPS = location.protocol === "https:";
|
||||
|
||||
const { addDevice } = useDeviceStore();
|
||||
const messageStore = useMessageStore();
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
|
||||
const { control, handleSubmit, register } = useForm<FormData>({
|
||||
@@ -39,23 +43,33 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
||||
} = useController({ name: "tls", control });
|
||||
|
||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null);
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setConnectionInProgress(true);
|
||||
const id = randId();
|
||||
const device = addDevice(id);
|
||||
const transport = await TransportHTTP.create(data.ip, data.tls);
|
||||
const connection = new MeshDevice(transport, id);
|
||||
connection.configure();
|
||||
setSelectedDevice(id);
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection);
|
||||
closeDialog();
|
||||
setConnectionError(null);
|
||||
|
||||
try {
|
||||
const id = randId();
|
||||
const transport = await TransportHTTP.create(data.ip, data.tls);
|
||||
const device = addDevice(id);
|
||||
const connection = new MeshDevice(transport, id);
|
||||
connection.configure();
|
||||
setSelectedDevice(id);
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection, messageStore);
|
||||
closeDialog();
|
||||
} catch (error) {
|
||||
console.error("Connection error:", error);
|
||||
// Capture all connection errors regardless of type
|
||||
setConnectionError({ host: data.ip, secure: data.tls });
|
||||
setConnectionInProgress(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
|
||||
<div className="flex h-48 flex-col gap-2">
|
||||
<div className="flex flex-col gap-2" style={{ minHeight: "12rem" }}>
|
||||
<div>
|
||||
<Label>IP Address/Hostname</Label>
|
||||
<Input
|
||||
@@ -74,8 +88,37 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
||||
{...register("tls")}
|
||||
/>
|
||||
<Label>Use HTTPS</Label>
|
||||
|
||||
</div>
|
||||
|
||||
{connectionError && (
|
||||
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
|
||||
<div className="flex gap-2 items-start">
|
||||
<AlertTriangle className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600" size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
|
||||
Connection Failed
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
|
||||
Could not connect to the device. {connectionError.secure && "If using HTTPS, you may need to accept a self-signed certificate first. "}
|
||||
Please open{" "}
|
||||
<Link
|
||||
href={`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
|
||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||
>
|
||||
{`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
|
||||
</Link>{" "}
|
||||
in a new tab{connectionError.secure ? ", accept any TLS warnings if prompted, then try again" : ""}.{" "}
|
||||
<Link
|
||||
href="https://meshtastic.org/docs/software/web-client/#http"
|
||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -8,10 +8,12 @@ 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";
|
||||
|
||||
export const Serial = ({ closeDialog }: TabElementProps) => {
|
||||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
||||
const { addDevice } = useDeviceStore();
|
||||
const messageStore = useMessageStore()
|
||||
const { setSelectedDevice } = useAppStore();
|
||||
|
||||
const updateSerialPortList = useCallback(async () => {
|
||||
@@ -36,7 +38,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
|
||||
const connection = new MeshDevice(transport, id);
|
||||
connection.configure();
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection);
|
||||
subscribeAll(device, connection, messageStore);
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -16,31 +16,53 @@ import {
|
||||
Dot,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MessageSquareIcon,
|
||||
MountainSnow,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@radix-ui/react-tooltip";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
export interface NodeDetailProps {
|
||||
node: ProtobufType.Mesh.NodeInfo;
|
||||
}
|
||||
|
||||
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
const { setChatType, setActiveChat } = useMessageStore();
|
||||
const { setActivePage } = useDevice();
|
||||
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
|
||||
const shortName = node.user?.shortName ?? "UNK";
|
||||
const hwModel = node.user?.hwModel ?? 0;
|
||||
const hardwareType =
|
||||
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
|
||||
|
||||
function handleDirectMessage() {
|
||||
setChatType(MessageType.Direct);
|
||||
setActiveChat(node.num);
|
||||
setActivePage("messages");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dark:text-slate-900 p-1">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||
<Avatar text={node.user?.shortName ?? "UNK"} />
|
||||
<Avatar text={shortName} />
|
||||
|
||||
<div>
|
||||
<div onFocusCapture={(e) => {
|
||||
// Required to prevent DM tooltip auto-appearing on creation
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||
? (
|
||||
<LockIcon
|
||||
className="text-green-600"
|
||||
className="text-green-600 mb-1.5"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label="Public Key Enabled"
|
||||
@@ -48,19 +70,41 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
)
|
||||
: (
|
||||
<LockOpenIcon
|
||||
className="text-yellow-500"
|
||||
className="text-yellow-500 mb-1.5"
|
||||
size={12}
|
||||
strokeWidth={3}
|
||||
aria-label="No Public Key"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Star
|
||||
fill={node.isFavorite ? "black" : "none"}
|
||||
size={15}
|
||||
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MessageSquareIcon
|
||||
size={14}
|
||||
onClick={handleDirectMessage}
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
Direct Message {shortName}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Star
|
||||
fill={node.isFavorite ? "black" : "none"}
|
||||
size={15}
|
||||
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -70,7 +114,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
|
||||
{!!node.deviceMetrics?.batteryLevel && (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
className="flex items-center gap-1 mt-0.5"
|
||||
title={`${node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
|
||||
} volts`}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||
import type { Message as Message } from "@core/stores/messageStore.ts";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
messages?: MessageWithState[];
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
const EmptyState = () => (
|
||||
@@ -17,8 +17,6 @@ const EmptyState = () => (
|
||||
export const ChannelChat = ({
|
||||
messages = [],
|
||||
}: ChannelChatProps) => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -57,14 +55,14 @@ export const ChannelChat = ({
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
|
||||
>
|
||||
<div className="flex flex-col justify-end min-h-full">
|
||||
<div className="flex flex-col gap-1.5 justify-end min-h-full">
|
||||
{messages?.map((message, index) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
<MessageItem
|
||||
key={message.messageId + index}
|
||||
message={message}
|
||||
sender={nodes.get(message.from)}
|
||||
lastMsgSameUser={
|
||||
index > 0 && messages[index - 1].from === message.from
|
||||
index > 0 &&
|
||||
messages[index - 1].from === message.from
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import {
|
||||
type MessageWithState,
|
||||
useDeviceStore,
|
||||
} from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
|
||||
|
||||
type MessageStateValue = {
|
||||
state: string;
|
||||
icon: LucideIcon;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
type MessageState = MessageWithState["state"];
|
||||
|
||||
interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
message: MessageWithState;
|
||||
sender: Protobuf.Mesh.NodeInfo;
|
||||
}
|
||||
|
||||
interface StatusTooltipProps {
|
||||
state: MessageState;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StatusIconProps {
|
||||
state: MessageState;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MESSAGE_STATES: Record<string, MessageStateValue> = {
|
||||
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
|
||||
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
|
||||
FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" },
|
||||
};
|
||||
|
||||
const getMessageState = (state: MessageState): MessageStateValue => {
|
||||
switch (state) {
|
||||
case MESSAGE_STATES.ACK.state:
|
||||
return MESSAGE_STATES.ACK;
|
||||
case MESSAGE_STATES.WAITING.state:
|
||||
return MESSAGE_STATES.WAITING;
|
||||
case MESSAGE_STATES.FAILED.state:
|
||||
return MESSAGE_STATES.FAILED;
|
||||
default:
|
||||
return MESSAGE_STATES.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{getMessageState(state).displayText ?? "An unknown error occurred"};
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
const msgState = getMessageState(state);
|
||||
|
||||
const isFailed = msgState.state === 'failed'
|
||||
|
||||
const iconClass = cn(
|
||||
className,
|
||||
"text-slate-500 dark:text-slate-400 size-5 shrink-0"
|
||||
);
|
||||
|
||||
const Icon = msgState.icon;
|
||||
|
||||
return (
|
||||
<StatusTooltip state={state}>
|
||||
<Icon
|
||||
className={iconClass}
|
||||
{...otherProps}
|
||||
color={isFailed ? "red" : "currentColor"}
|
||||
/>
|
||||
</StatusTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => (
|
||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const isDeviceUser = useMemo(
|
||||
() =>
|
||||
getDevices()
|
||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||
.includes(message.from),
|
||||
[getDevices, message.from]
|
||||
);
|
||||
|
||||
const messageUser = sender?.user;
|
||||
|
||||
const getMessageTextStyles = (state: MessageState) => {
|
||||
const msgState = getMessageState(state);
|
||||
const isAcknowledged = msgState.state === 'ack'
|
||||
const isFailed = msgState.state === 'failed'
|
||||
|
||||
return cn(
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged
|
||||
? "text-slate-900 dark:text-white"
|
||||
: "text-slate-900 dark:text-slate-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
);
|
||||
};
|
||||
|
||||
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full px-4 justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-wrap items-start py-1",
|
||||
isDeviceUser && "items-end"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimeDisplay date={message.rxTime} />
|
||||
<div className="flex place-items-center gap-2 pb-2">
|
||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>
|
||||
{message.data}
|
||||
</div>
|
||||
<StatusIcon state={message.state} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,152 +1,154 @@
|
||||
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx';
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MessageInput } from './MessageInput.tsx';
|
||||
import { useDevice } from '@core/stores/deviceStore.ts';
|
||||
import { useMessageStore } from '@core/stores/messageStore.ts';
|
||||
import { debounce } from '@core/utils/debounce.ts';
|
||||
import { Types } from "@meshtastic/core";
|
||||
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
||||
vi.mock('@components/UI/Button.tsx', () => ({
|
||||
Button: vi.fn(({ type, className, children, onClick, onSubmit }) => (
|
||||
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit}>
|
||||
{children}
|
||||
</button>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock('@components/UI/Input.tsx', () => ({
|
||||
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
minLength={minLength}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||
useDevice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@core/utils/debounce.ts", () => ({
|
||||
debounce: (fn: () => void) => fn,
|
||||
vi.mock('@core/stores/messageStore.ts', () => ({
|
||||
useMessageStore: vi.fn(),
|
||||
MessageState: {
|
||||
Ack: 'ack',
|
||||
Waiting: 'waiting',
|
||||
Failed: 'failed',
|
||||
},
|
||||
MessageType: {
|
||||
Direct: 'direct',
|
||||
Broadcast: 'broadcast',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@components/UI/Button.tsx", () => ({
|
||||
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>
|
||||
vi.mock('@core/utils/debounce.ts', () => ({
|
||||
debounce: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@components/UI/Input.tsx", () => ({
|
||||
Input: (props: any) => <input {...props} />
|
||||
vi.mock('lucide-react', () => ({
|
||||
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
SendIcon: () => <div data-testid="send-icon">Send</div>
|
||||
}));
|
||||
|
||||
// TODO: getting an error with this test
|
||||
describe('MessageInput Component', () => {
|
||||
const mockProps = {
|
||||
to: "broadcast" as const,
|
||||
channel: 0 as const,
|
||||
maxBytes: 100,
|
||||
};
|
||||
|
||||
const mockSetMessageDraft = vi.fn();
|
||||
describe('MessageInput', () => {
|
||||
const mockSetMessageState = vi.fn();
|
||||
const mockSendText = vi.fn().mockResolvedValue(123);
|
||||
const mockSetActiveChat = vi.fn();
|
||||
const mockSetDraft = vi.fn();
|
||||
const mockGetDraft = vi.fn();
|
||||
const mockClearDraft = vi.fn();
|
||||
const mockSendText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
(useDevice as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
connection: {
|
||||
sendText: mockSendText,
|
||||
},
|
||||
setMessageState: mockSetMessageState,
|
||||
messageDraft: "",
|
||||
setMessageDraft: mockSetMessageDraft,
|
||||
hardware: {
|
||||
myNodeNum: 1234567890,
|
||||
},
|
||||
});
|
||||
|
||||
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
setMessageState: mockSetMessageState,
|
||||
activeChat: 123,
|
||||
setDraft: mockSetDraft,
|
||||
getDraft: mockGetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
});
|
||||
|
||||
mockSetMessageState.mockClear();
|
||||
mockSetActiveChat.mockClear();
|
||||
mockSetDraft.mockClear();
|
||||
mockGetDraft.mockClear();
|
||||
mockClearDraft.mockClear();
|
||||
mockSendText.mockClear();
|
||||
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn);
|
||||
});
|
||||
|
||||
it('renders correctly with initial state', () => {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => {
|
||||
render(<MessageInput {...props} />);
|
||||
};
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates local draft and byte count when typing', () => {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
|
||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||
fireEvent.change(inputField, { target: { value: 'Hello' } })
|
||||
|
||||
expect(screen.getByText('5/100')).toBeInTheDocument();
|
||||
expect(inputField).toHaveValue('Hello');
|
||||
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
|
||||
});
|
||||
|
||||
it.skip('does not allow input exceeding max bytes', () => {
|
||||
render(<MessageInput {...mockProps} maxBytes={5} />);
|
||||
|
||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||
|
||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||
|
||||
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p')
|
||||
|
||||
expect(screen.getByText('100/100')).toBeInTheDocument();
|
||||
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
|
||||
});
|
||||
|
||||
it.skip('sends message and resets form when submitting', async () => {
|
||||
try {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
|
||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
||||
const submitButton = screen.getByText('Send');
|
||||
|
||||
fireEvent.change(inputField, { target: { value: 'Test Message' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const form = screen.getByRole('form');
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith(
|
||||
'broadcast',
|
||||
0,
|
||||
'broadcast',
|
||||
1234567890,
|
||||
123,
|
||||
'ack'
|
||||
);
|
||||
it.skip('sends text message and updates state to Ack on submit', async () => {
|
||||
renderComponent({ to: 2, channel: 3, maxBytes: 256 });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
fireEvent.change(inputElement, { target: { value: 'Hello' } });
|
||||
const formElement = screen.getByRole('form');
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'direct',
|
||||
key: 123,
|
||||
messageId: undefined,
|
||||
newState: 'ack',
|
||||
});
|
||||
|
||||
expect(inputField).toHaveValue('');
|
||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
||||
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
it('prevents sending empty messages', () => {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
|
||||
const form = screen.getByPlaceholderText('Enter Message')
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(mockSendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('initializes with existing message draft', () => {
|
||||
(useDevice as Mock).mockReturnValue({
|
||||
connection: {
|
||||
sendText: mockSendText,
|
||||
},
|
||||
setMessageState: mockSetMessageState,
|
||||
messageDraft: "Existing draft",
|
||||
setMessageDraft: mockSetMessageDraft,
|
||||
isQueueingMessages: false,
|
||||
queueStatus: { free: 10 },
|
||||
hardware: {
|
||||
myNodeNum: 1234567890,
|
||||
},
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(2);
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
});
|
||||
});
|
||||
|
||||
render(<MessageInput {...mockProps} />);
|
||||
it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => {
|
||||
renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
fireEvent.change(inputElement, { target: { value: 'Broadcast message' } });
|
||||
const formElement = screen.getByRole('form');
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
const inputField = screen.getByRole('textbox');
|
||||
await waitFor(() => {
|
||||
expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'broadcast',
|
||||
key: 123,
|
||||
messageId: undefined,
|
||||
newState: 'ack',
|
||||
});
|
||||
expect(mockClearDraft).toHaveBeenCalledWith('broadcast');
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
});
|
||||
});
|
||||
|
||||
expect(inputField).toHaveValue('Existing draft');
|
||||
it('updates state to Failed if sendText throws an error', async () => {
|
||||
mockSendText.mockRejectedValue({ id: 456 });
|
||||
renderComponent({ to: 3, channel: 1, maxBytes: 256 });
|
||||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||
fireEvent.change(inputElement, { target: { value: 'Error message' } });
|
||||
const formElement = screen.getByRole('form');
|
||||
fireEvent.submit(formElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'direct',
|
||||
key: 123,
|
||||
messageId: 456,
|
||||
newState: 'failed',
|
||||
});
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(3);
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
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";
|
||||
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";
|
||||
|
||||
export interface MessageInputProps {
|
||||
to: Types.Destination;
|
||||
@@ -17,57 +18,41 @@ export const MessageInput = ({
|
||||
channel,
|
||||
maxBytes,
|
||||
}: MessageInputProps) => {
|
||||
const {
|
||||
connection,
|
||||
setMessageState,
|
||||
messageDraft,
|
||||
setMessageDraft,
|
||||
isQueueingMessages,
|
||||
queueStatus,
|
||||
hardware,
|
||||
} = useDevice();
|
||||
const myNodeNum = hardware.myNodeNum;
|
||||
const [localDraft, setLocalDraft] = useState(messageDraft);
|
||||
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(setMessageDraft, 300),
|
||||
[setMessageDraft],
|
||||
() => debounce((value: string) => setDraft(to, value), 300),
|
||||
[setDraft, to]
|
||||
);
|
||||
|
||||
// sends the message to the selected destination
|
||||
const sendText = useCallback(
|
||||
async (message: string) => {
|
||||
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||
|
||||
await connection
|
||||
?.sendText(message, to, true, channel)
|
||||
.then((id: number) =>
|
||||
setMessageState(
|
||||
to === "broadcast" ? "broadcast" : "direct",
|
||||
channel,
|
||||
to as number,
|
||||
myNodeNum,
|
||||
id,
|
||||
"ack",
|
||||
)
|
||||
)
|
||||
.catch((e: Types.PacketError) =>
|
||||
setMessageState(
|
||||
to === "broadcast" ? "broadcast" : "direct",
|
||||
channel,
|
||||
to as number,
|
||||
myNodeNum,
|
||||
e.id,
|
||||
e.error,
|
||||
)
|
||||
);
|
||||
},
|
||||
[channel, connection, myNodeNum, setMessageState, to, queueStatus],
|
||||
);
|
||||
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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const byteLength = new Blob([newValue]).size;
|
||||
const byteLength = calculateBytes(newValue);
|
||||
|
||||
if (byteLength <= maxBytes) {
|
||||
setLocalDraft(newValue);
|
||||
@@ -76,26 +61,22 @@ export const MessageInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!localDraft.trim()) return;
|
||||
|
||||
startTransition(() => {
|
||||
sendText(localDraft.trim());
|
||||
setLocalDraft("");
|
||||
clearDraft(to);
|
||||
setMessageBytes(0);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
className="w-full"
|
||||
action={(formData: FormData) => {
|
||||
// prevent user from sending blank/empty message
|
||||
if (localDraft === "") return;
|
||||
const message = formData.get("messageInput") as string;
|
||||
startTransition(() => {
|
||||
if (!isQueueingMessages) {
|
||||
sendText(message);
|
||||
setLocalDraft("");
|
||||
setMessageDraft("");
|
||||
setMessageBytes(0);
|
||||
}
|
||||
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex grow gap-2 ">
|
||||
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}>
|
||||
<div className="flex grow gap-2">
|
||||
<label className="w-full">
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -106,11 +87,15 @@ export const MessageInput = ({
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label data-testid="byte-counter" className="flex items-center w-24 p-2 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">
|
||||
<Button
|
||||
type="submit"
|
||||
className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white"
|
||||
>
|
||||
<SendIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,8 @@ 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/services/types.ts";
|
||||
import { Message, MessageState, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
|
||||
interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
@@ -25,24 +26,20 @@ interface MessageStatus {
|
||||
}
|
||||
|
||||
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
|
||||
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 },
|
||||
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis },
|
||||
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle },
|
||||
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2 },
|
||||
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis },
|
||||
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle },
|
||||
};
|
||||
|
||||
const getMessageStatus = (state: MessageState): MessageStatus =>
|
||||
MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle };
|
||||
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle };
|
||||
|
||||
|
||||
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
<TooltipContent /* ...props... */ >
|
||||
{status.displayText}
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
@@ -51,76 +48,105 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children:
|
||||
);
|
||||
|
||||
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
|
||||
const isFailed = status.state === "failed";
|
||||
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className);
|
||||
const isFailed = status.state === MessageState.Failed;
|
||||
const iconClass = cn("w-4 h-4 shrink-0", className);
|
||||
const Icon = status.icon;
|
||||
|
||||
return (
|
||||
<StatusTooltip status={status}>
|
||||
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
|
||||
<Icon className={iconClass} {...otherProps} color={isFailed ? "currentColor" : undefined} />
|
||||
</StatusTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageTextStyles = (status: MessageStatus) => {
|
||||
const isAcknowledged = status.state === "ack";
|
||||
const isFailed = status.state === "failed";
|
||||
|
||||
const getMessageTextStyles = (status: MessageState, isDeviceUser: boolean) => {
|
||||
const isFailed = status === MessageState.Failed;
|
||||
return cn(
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
"break-words overflow-hidden whitespace-pre-wrap flex items-center gap-1.5",
|
||||
isFailed && (isDeviceUser ? "text-red-500" : "text-red-600 dark:text-red-500")
|
||||
);
|
||||
};
|
||||
|
||||
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
|
||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
|
||||
const _date = new Date(date);
|
||||
const locale = 'en-US'; // TODO: this should be dynamic based on user settings
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1 text-xs font-mono", className)}>
|
||||
<span>
|
||||
{_date?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true })}
|
||||
</span>
|
||||
{/* TODO: Conditionally show date for older messages? */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
||||
const myNodeNum = useMessageStore((state) => state.nodeNum);
|
||||
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const isDeviceUser = useMemo(
|
||||
() =>
|
||||
getDevices()
|
||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||
.includes(message.from),
|
||||
[getDevices, message.from],
|
||||
);
|
||||
const isDeviceUser = message.from === myNodeNum;
|
||||
|
||||
const messageUser = message?.from
|
||||
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
|
||||
: null;
|
||||
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
|
||||
if (message?.from === null || message?.from === undefined) return null;
|
||||
for (const device of getDevices()) {
|
||||
if (device.nodes.has(message.from)) {
|
||||
return device.nodes.get(message.from) ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [getDevices, message.from]);
|
||||
|
||||
const fallbackName = `${message.from}`;
|
||||
const longName = messageUser?.user?.longName;
|
||||
const shortName = messageUser?.user?.shortName ?? fallbackName.slice(0, 2).toUpperCase();
|
||||
const displayName = isDeviceUser ? "You" : (longName || fallbackName);
|
||||
|
||||
const messageContainerClass = cn(
|
||||
"flex flex-col w-full px-4 justify-start",
|
||||
!lastMsgSameUser ? "pt-3" : "pt-0.5"
|
||||
);
|
||||
const alignmentClass = cn(
|
||||
"flex flex-col flex-wrap w-full",
|
||||
isDeviceUser ? "items-end" : "items-start"
|
||||
);
|
||||
const bubbleBaseStyle = "flex flex-col max-w-[75%] rounded-lg px-3 py-1.5 text-sm shadow-md";
|
||||
const sentBubbleStyle = "bg-gradient-to-br from-blue-600 to-blue-700 dark:from-blue-500 dark:to-blue-600 text-white";
|
||||
const receivedBubbleStyle = "bg-slate-200 dark:bg-slate-500 text-slate-900 dark:text-white";
|
||||
const timeStatusColor = isDeviceUser ? "text-blue-100 dark:text-blue-200" : "text-slate-500 dark:text-slate-300";
|
||||
|
||||
const messageStatus = getMessageStatus(message.state);
|
||||
const messageTextClass = getMessageTextStyles(messageStatus);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full px-4 justify-start">
|
||||
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.user?.longName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimeDisplay date={message.date} />
|
||||
<div className="flex place-items-center gap-2 pb-2">
|
||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
|
||||
<StatusIcon status={messageStatus} />
|
||||
<div className={messageContainerClass}>
|
||||
<div className={alignmentClass}>
|
||||
|
||||
{/* Show only if not consecutive message AND not sent by self */}
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex items-center gap-1.5 mb-1 px-1">
|
||||
<Avatar text={shortName} />
|
||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
bubbleBaseStyle,
|
||||
isDeviceUser ? sentBubbleStyle : receivedBubbleStyle
|
||||
)}>
|
||||
<div className={cn("flex items-center gap-1.5 mt-1 self-end", timeStatusColor)}>
|
||||
<TimeDisplay date={message.date} />
|
||||
</div>
|
||||
|
||||
<div className={cn(getMessageTextStyles(message.state, isDeviceUser))}>
|
||||
{message.message || <span className="italic opacity-70">Empty message</span>}
|
||||
{isDeviceUser && <StatusIcon status={messageStatus} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+55
-41
@@ -3,6 +3,8 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Page } from "@core/stores/deviceStore.ts";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
|
||||
import {
|
||||
BatteryMediumIcon,
|
||||
CpuIcon,
|
||||
@@ -58,7 +60,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
||||
page: "channels",
|
||||
},
|
||||
{
|
||||
name: `Nodes (${nodes.size})`,
|
||||
name: `Nodes (${nodes.size - 1})`,
|
||||
icon: UsersIcon,
|
||||
page: "nodes",
|
||||
},
|
||||
@@ -67,46 +69,55 @@ 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-400">
|
||||
<div className="flex justify-between px-8 pt-6">
|
||||
<div>
|
||||
<span className="text-lg font-medium">
|
||||
{myNode?.user?.shortName ?? "UNK"}
|
||||
</span>
|
||||
<Subtle>{myNode?.user?.longName ?? "UNK"}</Subtle>
|
||||
{myNode === undefined ? (
|
||||
<div className="flex flex-col items-center justify-center px-8 py-6">
|
||||
<Spinner />
|
||||
<Subtle className="mt-2">Loading device info...</Subtle>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={() => setDialogOpen("deviceName", true)}
|
||||
>
|
||||
<EditIcon size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowSidebar(false)}>
|
||||
<SidebarCloseIcon size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-8 pb-6">
|
||||
<div className="flex items-center">
|
||||
<BatteryMediumIcon size={24} viewBox="0 0 28 24" />
|
||||
<Subtle>
|
||||
{myNode?.deviceMetrics?.batteryLevel
|
||||
? myNode?.deviceMetrics?.batteryLevel > 100
|
||||
? "Charging"
|
||||
: `${myNode?.deviceMetrics?.batteryLevel}%`
|
||||
: "UNK"}
|
||||
</Subtle>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ZapIcon size={24} viewBox="0 0 36 24" />
|
||||
<Subtle>
|
||||
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
|
||||
</Subtle>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CpuIcon size={24} viewBox="0 0 36 24" />
|
||||
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between px-8 pt-6">
|
||||
<div>
|
||||
<span className="text-lg font-medium">
|
||||
{myNode.user?.shortName ?? "UNK"}
|
||||
</span>
|
||||
<Subtle>{myNode.user?.longName ?? "UNK"}</Subtle>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={() => setDialogOpen("deviceName", true)}
|
||||
>
|
||||
<EditIcon size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowSidebar(false)}>
|
||||
<SidebarCloseIcon size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-8 pb-6">
|
||||
<div className="flex items-center">
|
||||
<BatteryMediumIcon size={24} viewBox="0 0 28 24" />
|
||||
<Subtle>
|
||||
{myNode.deviceMetrics?.batteryLevel
|
||||
? myNode.deviceMetrics.batteryLevel > 100
|
||||
? "Charging"
|
||||
: `${myNode.deviceMetrics.batteryLevel}%`
|
||||
: "UNK"}
|
||||
</Subtle>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ZapIcon size={24} viewBox="0 0 36 24" />
|
||||
<Subtle>
|
||||
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
|
||||
</Subtle>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CpuIcon size={24} viewBox="0 0 36 24" />
|
||||
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SidebarSection label="Navigation">
|
||||
{pages.map((link) => (
|
||||
@@ -115,9 +126,12 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
||||
label={link.name}
|
||||
Icon={link.icon}
|
||||
onClick={() => {
|
||||
setActivePage(link.page);
|
||||
if (myNode !== undefined) {
|
||||
setActivePage(link.page);
|
||||
}
|
||||
}}
|
||||
active={link.page === activePage}
|
||||
disabled={myNode === undefined}
|
||||
/>
|
||||
))}
|
||||
</SidebarSection>
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "./UI/Toast.tsx";
|
||||
import { useToast } from "../core/hooks/useToast.ts";
|
||||
} from "@components/UI/Toast.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
@@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-hidden aria-selected:bg-slate-100 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 dark:aria-selected:bg-slate-700",
|
||||
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-hidden aria-selected:bg-slate-100 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 dark:aria-selected:bg-slate-700 dark:text-white ",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface SidebarButtonProps {
|
||||
Icon?: LucideIcon;
|
||||
element?;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SidebarButton = ({
|
||||
@@ -17,12 +18,14 @@ export const SidebarButton = ({
|
||||
count,
|
||||
element,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: SidebarButtonProps) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant={active ? "subtle" : "ghost"}
|
||||
size="sm"
|
||||
className="flex gap-2 w-full"
|
||||
disabled={disabled}
|
||||
>
|
||||
{Icon && <Icon size={16} />}
|
||||
{element && element}
|
||||
|
||||
Reference in New Issue
Block a user