Merge pull request #536 from danditomaso/add-message-persistance
Add message persistence using IndexedDB
This commit is contained in:
@@ -45,6 +45,7 @@
|
||||
"npm:crypto-random-string@5": "5.0.0",
|
||||
"npm:gzipper@^8.2.1": "8.2.1",
|
||||
"npm:happy-dom@^17.4.4": "17.4.4",
|
||||
"npm:idb-keyval@^6.2.1": "6.2.1",
|
||||
"npm:immer@^10.1.1": "10.1.1",
|
||||
"npm:js-cookie@^3.0.5": "3.0.5",
|
||||
"npm:lucide-react@0.486": "0.486.0_react@19.1.0",
|
||||
@@ -4492,6 +4493,9 @@
|
||||
"https-browserify@1.0.0": {
|
||||
"integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg=="
|
||||
},
|
||||
"idb-keyval@6.2.1": {
|
||||
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
|
||||
},
|
||||
"idb@7.1.1": {
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="
|
||||
},
|
||||
@@ -6491,6 +6495,7 @@
|
||||
"npm:crypto-random-string@5",
|
||||
"npm:gzipper@^8.2.1",
|
||||
"npm:happy-dom@^17.4.4",
|
||||
"npm:idb-keyval@^6.2.1",
|
||||
"npm:immer@^10.1.1",
|
||||
"npm:js-cookie@^3.0.5",
|
||||
"npm:lucide-react@0.486",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.486.0",
|
||||
|
||||
@@ -219,10 +219,10 @@ export const CommandPalette = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "[WIP] Clear Messages",
|
||||
label: "Clear All Stored Message",
|
||||
icon: EraserIcon,
|
||||
action() {
|
||||
alert("This feature is not implemented");
|
||||
setDialogOpen("clearMessages", true);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,8 @@ import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDeta
|
||||
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();
|
||||
@@ -84,6 +86,12 @@ export const DialogManager = () => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
}));
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TransportHTTP } from "@meshtastic/transport-http";
|
||||
import { useState } from "react";
|
||||
import { useForm, useController } from "react-hook-form";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
interface FormData {
|
||||
ip: string;
|
||||
@@ -23,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>({
|
||||
@@ -55,7 +57,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
||||
connection.configure();
|
||||
setSelectedDevice(id);
|
||||
device.addConnection(connection);
|
||||
subscribeAll(device, connection);
|
||||
subscribeAll(device, connection, messageStore);
|
||||
closeDialog();
|
||||
} catch (error) {
|
||||
console.error("Connection error:", error);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
setMessageState: mockSetMessageState,
|
||||
messageDraft: "",
|
||||
setMessageDraft: mockSetMessageDraft,
|
||||
hardware: {
|
||||
myNodeNum: 1234567890,
|
||||
},
|
||||
});
|
||||
activeChat: 123,
|
||||
setDraft: mockSetDraft,
|
||||
getDraft: mockGetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
});
|
||||
|
||||
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();
|
||||
mockSetMessageState.mockClear();
|
||||
mockSetActiveChat.mockClear();
|
||||
mockSetDraft.mockClear();
|
||||
mockGetDraft.mockClear();
|
||||
mockClearDraft.mockClear();
|
||||
mockSendText.mockClear();
|
||||
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn);
|
||||
});
|
||||
|
||||
it('updates local draft and byte count when typing', () => {
|
||||
render(<MessageInput {...mockProps} />);
|
||||
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => {
|
||||
render(<MessageInput {...props} />);
|
||||
};
|
||||
|
||||
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);
|
||||
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(mockSetMessageState).toHaveBeenCalledWith(
|
||||
'broadcast',
|
||||
0,
|
||||
'broadcast',
|
||||
1234567890,
|
||||
123,
|
||||
'ack'
|
||||
);
|
||||
expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3);
|
||||
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||
type: 'direct',
|
||||
key: 123,
|
||||
messageId: undefined,
|
||||
newState: 'ack',
|
||||
});
|
||||
expect(mockClearDraft).toHaveBeenCalledWith(2);
|
||||
expect(inputElement.value).toBe('');
|
||||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MessageInput {...mockProps} />);
|
||||
|
||||
const inputField = screen.getByRole('textbox');
|
||||
|
||||
expect(inputField).toHaveValue('Existing draft');
|
||||
});
|
||||
});
|
||||
@@ -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,25 +61,21 @@ 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);
|
||||
}
|
||||
|
||||
});
|
||||
}}
|
||||
>
|
||||
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}>
|
||||
<div className="flex grow gap-2">
|
||||
<label className="w-full">
|
||||
<Input
|
||||
@@ -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" })}
|
||||
|
||||
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">
|
||||
<div className={messageContainerClass}>
|
||||
<div className={alignmentClass}>
|
||||
|
||||
{/* Show only if not consecutive message AND not sent by self */}
|
||||
{!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}
|
||||
<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>
|
||||
)}
|
||||
</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 className="flex place-items-center gap-2 pb-2">
|
||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
|
||||
<StatusIcon status={messageStatus} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts";
|
||||
|
||||
class PacketToMessageDTO {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: number; // (timestamp ms)
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
type: MessageType;
|
||||
|
||||
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
|
||||
this.channel = data.channel;
|
||||
this.to = data.to;
|
||||
this.from = data.from;
|
||||
this.messageId = data.id;
|
||||
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
|
||||
this.message = data.data;
|
||||
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast;
|
||||
|
||||
let dateTimestamp = Date.now();
|
||||
if (data.rxTime instanceof Date) {
|
||||
const timeValue = data.rxTime.getTime();
|
||||
|
||||
if (!isNaN(timeValue)) {
|
||||
dateTimestamp = timeValue;
|
||||
}
|
||||
}
|
||||
else if (data.rxTime != null) {
|
||||
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`);
|
||||
}
|
||||
this.date = dateTimestamp;
|
||||
}
|
||||
|
||||
toMessage(): Message {
|
||||
return {
|
||||
channel: this.channel,
|
||||
to: this.to,
|
||||
from: this.from,
|
||||
date: this.date,
|
||||
messageId: this.messageId,
|
||||
state: this.state,
|
||||
message: this.message,
|
||||
type: this.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default PacketToMessageDTO;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Types } from "@meshtastic/core";
|
||||
import { produce } from "immer";
|
||||
import { create } from "zustand";
|
||||
|
||||
@@ -25,14 +24,11 @@ interface AppState {
|
||||
id: number;
|
||||
num: number;
|
||||
}[];
|
||||
|
||||
rasterSources: RasterSource[];
|
||||
commandPaletteOpen: boolean;
|
||||
nodeNumToBeRemoved: number;
|
||||
connectDialogOpen: boolean;
|
||||
nodeNumDetails: number;
|
||||
activeChat: number;
|
||||
chatType: "broadcast" | "direct";
|
||||
errors: ErrorState[];
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => void;
|
||||
@@ -45,8 +41,6 @@ interface AppState {
|
||||
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
||||
setConnectDialogOpen: (open: boolean) => void;
|
||||
setNodeNumDetails: (nodeNum: number) => void;
|
||||
setActiveChat: (chat: number) => void;
|
||||
setChatType: (type: "broadcast" | "direct") => void;
|
||||
|
||||
// Error management
|
||||
hasErrors: () => boolean;
|
||||
@@ -67,8 +61,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
|
||||
connectDialogOpen: false,
|
||||
nodeNumToBeRemoved: 0,
|
||||
nodeNumDetails: 0,
|
||||
activeChat: Types.ChannelNumber.Primary,
|
||||
chatType: "broadcast",
|
||||
errors: [],
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => {
|
||||
@@ -127,14 +119,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
|
||||
set(() => ({
|
||||
nodeNumDetails: nodeNum,
|
||||
})),
|
||||
setActiveChat: (chat) =>
|
||||
set(() => ({
|
||||
activeChat: chat,
|
||||
})),
|
||||
setChatType: (type) =>
|
||||
set(() => ({
|
||||
chatType: type,
|
||||
})),
|
||||
hasErrors: () => {
|
||||
const state = get();
|
||||
return state.errors.length > 0;
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface MessageWithState extends Types.PacketMetadata<string> {
|
||||
state: MessageState;
|
||||
}
|
||||
|
||||
export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error;
|
||||
export type MessageState = "ack" | "waiting" | 'failed';
|
||||
|
||||
export interface ProcessPacketParams {
|
||||
from: number;
|
||||
@@ -29,11 +29,8 @@ export type DialogVariant =
|
||||
| "pkiBackup"
|
||||
| "nodeDetails"
|
||||
| "unsafeRoles"
|
||||
| "refreshKeys";
|
||||
|
||||
type QueueStatus = {
|
||||
res: number, free: number, maxlen: number
|
||||
}
|
||||
| "refreshKeys"
|
||||
| "clearMessages";
|
||||
|
||||
type NodeError = {
|
||||
node: number;
|
||||
@@ -51,10 +48,6 @@ export interface Device {
|
||||
hardware: Protobuf.Mesh.MyNodeInfo;
|
||||
nodes: Map<number, Protobuf.Mesh.NodeInfo>;
|
||||
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
||||
messages: {
|
||||
direct: Map<number, MessageWithState[]>;
|
||||
broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
|
||||
};
|
||||
traceroutes: Map<
|
||||
number,
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||
@@ -67,8 +60,6 @@ export interface Device {
|
||||
// currentMetrics: Protobuf.DeviceMetrics;
|
||||
pendingSettingsChanges: boolean;
|
||||
messageDraft: string;
|
||||
queueStatus: QueueStatus,
|
||||
isQueueingMessages: boolean,
|
||||
dialog: {
|
||||
import: boolean;
|
||||
QR: boolean;
|
||||
@@ -81,6 +72,7 @@ export interface Device {
|
||||
nodeDetails: boolean;
|
||||
unsafeRoles: boolean;
|
||||
refreshKeys: boolean;
|
||||
clearMessages: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -100,25 +92,15 @@ export interface Device {
|
||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||
addConnection: (connection: MeshDevice) => void;
|
||||
addMessage: (message: MessageWithState) => void;
|
||||
addTraceRoute: (
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||
) => void;
|
||||
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
|
||||
removeNode: (nodeNum: number) => void;
|
||||
setMessageState: (
|
||||
type: "direct" | "broadcast",
|
||||
channelIndex: Types.ChannelNumber,
|
||||
to: number,
|
||||
from: number,
|
||||
messageId: number,
|
||||
state: MessageState,
|
||||
) => void;
|
||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
|
||||
getDialogOpen: (dialog: DialogVariant) => boolean;
|
||||
processPacket: (data: ProcessPacketParams) => void;
|
||||
setMessageDraft: (message: string) => void;
|
||||
setQueueStatus: (status: QueueStatus) => void;
|
||||
setNodeError: (nodeNum: number, error: string) => void;
|
||||
clearNodeError: (nodeNum: number) => void;
|
||||
getNodeError: (nodeNum: number) => NodeError | undefined;
|
||||
@@ -153,19 +135,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
||||
nodes: new Map(),
|
||||
metadata: new Map(),
|
||||
messages: {
|
||||
direct: new Map(),
|
||||
broadcast: new Map(),
|
||||
},
|
||||
traceroutes: new Map(),
|
||||
connection: undefined,
|
||||
activePage: "messages",
|
||||
activeNode: 0,
|
||||
waypoints: [],
|
||||
queueStatus: {
|
||||
res: 0, free: 0, maxlen: 0
|
||||
},
|
||||
isQueueingMessages: false,
|
||||
dialog: {
|
||||
import: false,
|
||||
QR: false,
|
||||
@@ -510,31 +484,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
addMessage: (message) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const messageGroup = device.messages[message.type];
|
||||
const messageIndex = message.type === "direct"
|
||||
? message.from === device.hardware.myNodeNum
|
||||
? message.to
|
||||
: message.from
|
||||
: message.channel;
|
||||
const messages = messageGroup.get(messageIndex);
|
||||
|
||||
if (messages) {
|
||||
messages.push(message);
|
||||
messageGroup.set(messageIndex, messages);
|
||||
} else {
|
||||
messageGroup.set(messageIndex, [message]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
addMetadata: (from, metadata) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
@@ -575,43 +524,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
setMessageState: (
|
||||
type: "direct" | "broadcast",
|
||||
channelIndex: Types.ChannelNumber,
|
||||
to: number,
|
||||
from: number,
|
||||
messageId: number,
|
||||
state: MessageState,
|
||||
) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const messageGroup = device.messages[type];
|
||||
|
||||
const messageIndex = type === "direct"
|
||||
? from === device.hardware.myNodeNum ? to : from
|
||||
: channelIndex;
|
||||
const messages = messageGroup.get(messageIndex);
|
||||
|
||||
if (!messages) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageGroup.set(
|
||||
messageIndex,
|
||||
messages.map((msg) => {
|
||||
if (msg.id === messageId) {
|
||||
msg.state = state;
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
@@ -667,17 +579,6 @@ 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
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNodeError: (nodeNum, error) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useMessageStore,
|
||||
MessageType,
|
||||
MessageState,
|
||||
type Message,
|
||||
} from './messageStore.ts';
|
||||
|
||||
let memoryStorage: Record<string, string> = {};
|
||||
|
||||
vi.mock('./storage/indexDB.ts', () => {
|
||||
console.log("Mocking zustandIndexDBStorage...");
|
||||
return {
|
||||
zustandIndexDBStorage: {
|
||||
getItem: vi.fn(async (name: string): Promise<string | null> => {
|
||||
console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null);
|
||||
return memoryStorage[name] ?? null;
|
||||
}),
|
||||
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
|
||||
console.log(`Mock setItem: ${name}`, value);
|
||||
memoryStorage[name] = value;
|
||||
}),
|
||||
removeItem: vi.fn(async (name: string): Promise<void> => {
|
||||
console.log(`Mock removeItem: ${name}`);
|
||||
delete memoryStorage[name];
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const myNodeNum = 111;
|
||||
const otherNodeNum1 = 222;
|
||||
const otherNodeNum2 = 333;
|
||||
const broadcastChannel = 0;
|
||||
|
||||
|
||||
|
||||
const directMessageToOther1: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum1,
|
||||
channel: 0,
|
||||
date: Date.now(),
|
||||
messageId: 101,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello other 1 from me',
|
||||
};
|
||||
|
||||
const directMessageFromOther1: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: otherNodeNum1,
|
||||
to: myNodeNum,
|
||||
channel: 0,
|
||||
date: Date.now() + 1000,
|
||||
messageId: 102,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello me from other 1',
|
||||
};
|
||||
|
||||
const directMessageToOther2: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum2,
|
||||
channel: 0,
|
||||
date: Date.now() + 2000,
|
||||
messageId: 103,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello other 2 from me',
|
||||
};
|
||||
|
||||
const broadcastMessage1: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
from: otherNodeNum1,
|
||||
to: 0xffffffff,
|
||||
channel: broadcastChannel,
|
||||
date: Date.now() + 3000,
|
||||
messageId: 201,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast message 1',
|
||||
};
|
||||
|
||||
const broadcastMessage2: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
from: myNodeNum,
|
||||
to: 0xffffffff,
|
||||
channel: broadcastChannel,
|
||||
date: Date.now() + 4000,
|
||||
messageId: 202,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast message 2',
|
||||
};
|
||||
|
||||
describe('useMessageStore', () => {
|
||||
const initialState = useMessageStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageStore.setState(initialState, true);
|
||||
});
|
||||
|
||||
it('should have correct initial state', () => {
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct).toEqual({});
|
||||
expect(state.messages.broadcast).toEqual({});
|
||||
expect(state.draft).toBeInstanceOf(Map);
|
||||
expect(state.draft.size).toBe(0);
|
||||
expect(state.nodeNum).toBe(0);
|
||||
expect(state.activeChat).toBe(0);
|
||||
expect(state.chatType).toBe(MessageType.Broadcast);
|
||||
});
|
||||
|
||||
it('should set nodeNum', () => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
|
||||
});
|
||||
|
||||
it('should set activeChat and chatType', () => {
|
||||
useMessageStore.getState().setActiveChat(otherNodeNum1);
|
||||
useMessageStore.getState().setChatType(MessageType.Direct);
|
||||
expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1);
|
||||
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
|
||||
});
|
||||
|
||||
describe('saveMessage', () => {
|
||||
it('should save a direct message with correct structure', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct[myNodeNum]).toBeDefined();
|
||||
expect(state.messages.direct[myNodeNum][otherNodeNum1]).toBeDefined();
|
||||
expect(
|
||||
state.messages.direct[myNodeNum][otherNodeNum1][directMessageToOther1.messageId],
|
||||
).toEqual(directMessageToOther1);
|
||||
});
|
||||
|
||||
it('should save a broadcast message with correct structure', () => {
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.broadcast[broadcastChannel]).toBeDefined();
|
||||
expect(
|
||||
state.messages.broadcast[broadcastChannel][broadcastMessage1.messageId],
|
||||
).toEqual(broadcastMessage1);
|
||||
});
|
||||
|
||||
it('should save multiple messages correctly', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
// Direct msg 1 (me -> other1)
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1);
|
||||
// Direct msg 2 (other1 -> me)
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1);
|
||||
// Broadcast msg 1
|
||||
expect(state.messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]).toEqual(broadcastMessage1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessages', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther2);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage2);
|
||||
});
|
||||
|
||||
it('should return broadcast messages for a channel, sorted by date', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, {
|
||||
myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice
|
||||
channel: broadcastChannel
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(broadcastMessage1);
|
||||
expect(messages[1]).toEqual(broadcastMessage2);
|
||||
});
|
||||
|
||||
it('should return empty array for broadcast if channel has no messages', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, {
|
||||
myNodeNum: myNodeNum,
|
||||
channel: 99
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return combined direct messages for a specific chat (pair), sorted by date', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: otherNodeNum1
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(directMessageToOther1);
|
||||
expect(messages[1]).toEqual(directMessageFromOther1);
|
||||
});
|
||||
|
||||
it('should return only relevant direct messages for a different chat pair', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: otherNodeNum2
|
||||
});
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(directMessageToOther2);
|
||||
});
|
||||
|
||||
it('should return empty array for direct chat if no messages exist', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: 999
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array if myNodeNum is not provided for direct messages', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
otherNodeNum: otherNodeNum1
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMessageState', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
});
|
||||
|
||||
it('should update state for a direct message sent BY ME', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: otherNodeNum1,
|
||||
messageId: directMessageToOther1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const message = useMessageStore.getState().messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId];
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('should update state for a direct message received FROM OTHER', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: otherNodeNum1,
|
||||
messageId: directMessageFromOther1.messageId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId];
|
||||
expect(message?.state).toBe(MessageState.Failed);
|
||||
});
|
||||
|
||||
it('should update state for a broadcast message', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
key: broadcastChannel,
|
||||
messageId: broadcastMessage1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId];
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('should warn if message is not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: otherNodeNum1,
|
||||
messageId: 999,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message not found for state update'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('clearMessageByMessageId', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: 1011, date: Date.now() + 50 });
|
||||
});
|
||||
|
||||
it('should delete a specific direct message (sent by me)', () => {
|
||||
const messageIdToDelete = directMessageToOther1.messageId;
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
sender: myNodeNum,
|
||||
recipient: otherNodeNum1,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[messageIdToDelete]).toBeUndefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delete a specific direct message (sent by other)', () => {
|
||||
const messageIdToDelete = directMessageFromOther1.messageId;
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
sender: otherNodeNum1,
|
||||
recipient: myNodeNum,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[messageIdToDelete]).toBeUndefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toBeDefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delete a specific broadcast message', () => {
|
||||
const messageIdToDelete = broadcastMessage1.messageId;
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Broadcast,
|
||||
channel: broadcastChannel,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up empty recipient/sender/channel objects', () => {
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, sender: otherNodeNum1, recipient: myNodeNum, messageId: directMessageFromOther1.messageId });
|
||||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed
|
||||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channel: broadcastChannel, messageId: broadcastMessage1.messageId });
|
||||
expect(useMessageStore.getState().messages.broadcast[broadcastChannel]).toBeUndefined(); // Channel level removed
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drafts', () => {
|
||||
const draftKey = otherNodeNum1;
|
||||
const draftMessage = 'This is a draft';
|
||||
|
||||
it('should set and get a draft', () => {
|
||||
useMessageStore.getState().setDraft(draftKey, draftMessage);
|
||||
expect(useMessageStore.getState().draft.get(draftKey)).toBe(draftMessage);
|
||||
expect(useMessageStore.getState().getDraft(draftKey)).toBe(draftMessage);
|
||||
});
|
||||
|
||||
it('should return empty string for non-existent draft', () => {
|
||||
expect(useMessageStore.getState().getDraft(999)).toBe('');
|
||||
});
|
||||
|
||||
it('should clear a draft', () => {
|
||||
useMessageStore.getState().setDraft(draftKey, draftMessage);
|
||||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(true);
|
||||
useMessageStore.getState().clearDraft(draftKey);
|
||||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(false);
|
||||
expect(useMessageStore.getState().getDraft(draftKey)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllMessages', () => {
|
||||
it('should clear all direct and broadcast messages', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0);
|
||||
|
||||
useMessageStore.getState().clearAllMessages();
|
||||
|
||||
expect(useMessageStore.getState().messages.direct).toEqual({});
|
||||
expect(useMessageStore.getState().messages.broadcast).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { produce } from 'immer';
|
||||
import { Types } from '@meshtastic/core';
|
||||
import { zustandIndexDBStorage } from "./storage/indexDB.ts";
|
||||
|
||||
export enum MessageState {
|
||||
Ack = "ack",
|
||||
Waiting = "waiting",
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
export enum MessageType {
|
||||
Direct = "direct",
|
||||
Broadcast = "broadcast",
|
||||
}
|
||||
|
||||
interface MessageBase {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: number;
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface GenericMessage<T extends MessageType> extends MessageBase {
|
||||
type: T;
|
||||
}
|
||||
|
||||
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
|
||||
|
||||
export interface MessageStore {
|
||||
messages: {
|
||||
direct: Record<number, Record<number, Record<number, Message>>>;
|
||||
broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
|
||||
};
|
||||
draft: Map<Types.Destination, string>;
|
||||
nodeNum: number; // This device's node number
|
||||
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
|
||||
chatType: MessageType;
|
||||
|
||||
setNodeNum: (nodeNum: number) => void;
|
||||
getNodeNum: () => number;
|
||||
setActiveChat: (chat: number) => void;
|
||||
setChatType: (type: MessageType) => void;
|
||||
saveMessage: (message: Message) => void;
|
||||
setMessageState: (params: {
|
||||
type: MessageType;
|
||||
// For Direct: Represents the *other* node number involved in the chat.
|
||||
// For Broadcast: Represents the channel number.
|
||||
key: number;
|
||||
messageId: number;
|
||||
newState?: MessageState;
|
||||
}) => void;
|
||||
getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[];
|
||||
getDraft: (key: Types.Destination) => string;
|
||||
setDraft: (key: Types.Destination, message: string) => void;
|
||||
clearAllMessages: () => void;
|
||||
clearMessageByMessageId: (params: {
|
||||
type: MessageType;
|
||||
sender?: number;
|
||||
recipient?: number;
|
||||
channel?: number;
|
||||
messageId: number
|
||||
}) => void;
|
||||
clearDraft: (key: Types.Destination) => void;
|
||||
}
|
||||
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
|
||||
export const useMessageStore = create<MessageStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messages: {
|
||||
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
|
||||
broadcast: {},
|
||||
},
|
||||
draft: new Map<number, string>(),
|
||||
activeChat: 0,
|
||||
chatType: MessageType.Broadcast,
|
||||
nodeNum: 0,
|
||||
setNodeNum: (nodeNum) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.nodeNum = nodeNum;
|
||||
}));
|
||||
},
|
||||
getNodeNum: () => get().nodeNum,
|
||||
setActiveChat: (chat) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.activeChat = chat;
|
||||
}));
|
||||
},
|
||||
setChatType: (type) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.chatType = type;
|
||||
}));
|
||||
},
|
||||
saveMessage: (message) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
if (message.type === MessageType.Direct) {
|
||||
const sender = Number(message.from);
|
||||
const recipient = Number(message.to);
|
||||
|
||||
if (!state.messages.direct[sender]) {
|
||||
state.messages.direct[sender] = {};
|
||||
}
|
||||
if (!state.messages.direct[sender][recipient]) {
|
||||
state.messages.direct[sender][recipient] = {};
|
||||
}
|
||||
state.messages.direct[sender][recipient][message.messageId] = message;
|
||||
|
||||
} else if (message.type === MessageType.Broadcast) {
|
||||
const channel = Number(message.channel);
|
||||
if (!state.messages.broadcast[channel]) {
|
||||
state.messages.broadcast[channel] = {};
|
||||
}
|
||||
state.messages.broadcast[channel][message.messageId] = message;
|
||||
}
|
||||
}));
|
||||
},
|
||||
setMessageState: ({
|
||||
type,
|
||||
key,
|
||||
messageId,
|
||||
newState = MessageState.Ack,
|
||||
}) => {
|
||||
set(
|
||||
produce((state: MessageStore) => {
|
||||
let message: Message | undefined;
|
||||
|
||||
if (type === MessageType.Broadcast) {
|
||||
const channel = key;
|
||||
message = state.messages.broadcast?.[channel]?.[messageId];
|
||||
} else if (type === MessageType.Direct) {
|
||||
const otherNodeNum = key;
|
||||
const myNodeNum = state.nodeNum;
|
||||
|
||||
message = state.messages.direct?.[myNodeNum]?.[otherNodeNum]?.[messageId];
|
||||
|
||||
if (!message) {
|
||||
message = state.messages.direct?.[otherNodeNum]?.[myNodeNum]?.[messageId];
|
||||
}
|
||||
}
|
||||
|
||||
if (message) {
|
||||
message.state = newState;
|
||||
} else {
|
||||
console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
getMessages: (type, options) => {
|
||||
const state = get();
|
||||
|
||||
if (type === MessageType.Broadcast && options.channel !== undefined) {
|
||||
const messageMap = state.messages.broadcast[options.channel] ?? {};
|
||||
return Object.values(messageMap).sort((a, b) => a.date - b.date);
|
||||
}
|
||||
|
||||
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
|
||||
const myNodeNum = options.myNodeNum;
|
||||
const otherNodeNum = options.otherNodeNum;
|
||||
|
||||
// Messages sent BY ME TO OTHER
|
||||
const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {};
|
||||
const sentByMe = Object.values(sentByMeMap);
|
||||
|
||||
// Messages sent BY OTHER TO ME
|
||||
const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {};
|
||||
const sentByOther = Object.values(sentByOtherMap);
|
||||
|
||||
// Merge and sort chronologically
|
||||
return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
clearMessageByMessageId: ({ type, sender, recipient, channel, messageId }) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
if (type === MessageType.Broadcast && channel !== undefined) {
|
||||
const messageMap = state.messages.broadcast[channel];
|
||||
if (messageMap?.[messageId]) {
|
||||
delete messageMap[messageId];
|
||||
if (Object.keys(messageMap).length === 0) {
|
||||
delete state.messages.broadcast[channel];
|
||||
}
|
||||
}
|
||||
} else if (type === MessageType.Direct && sender !== undefined && recipient !== undefined) {
|
||||
const messageMap = state.messages.direct?.[sender]?.[recipient];
|
||||
if (messageMap?.[messageId]) {
|
||||
delete messageMap[messageId];
|
||||
if (Object.keys(messageMap).length === 0) {
|
||||
delete state.messages.direct[sender][recipient];
|
||||
if (Object.keys(state.messages.direct[sender]).length === 0) {
|
||||
delete state.messages.direct[sender];
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn("clearMessageByMessageId called without sufficient identifiers for type", type);
|
||||
}
|
||||
}));
|
||||
},
|
||||
getDraft: (key) => {
|
||||
return get().draft.get(key) ?? '';
|
||||
},
|
||||
setDraft: (key, message) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.draft.set(key, message);
|
||||
}));
|
||||
},
|
||||
clearDraft: (key) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.draft.delete(key);
|
||||
}));
|
||||
},
|
||||
clearAllMessages: () => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.messages.direct = {};
|
||||
state.messages.broadcast = {};
|
||||
}));
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'meshtastic-message-store',
|
||||
storage: createJSONStorage(() => zustandIndexDBStorage),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (state) => ({
|
||||
messages: state.messages,
|
||||
nodeNum: state.nodeNum,
|
||||
}),
|
||||
}
|
||||
));
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StateStorage } from "zustand/middleware";
|
||||
import { get, set, del } from "idb-keyval";
|
||||
|
||||
export const zustandIndexDBStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
return (await get(name)) || null;
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await set(name, value);
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await del(name);
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Device } from "@core/stores/deviceStore.ts";
|
||||
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
||||
import type { MessageStore } from "@core/stores/messageStore.ts";
|
||||
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||
|
||||
export const subscribeAll = (
|
||||
device: Device,
|
||||
connection: MeshDevice,
|
||||
messageStore: MessageStore
|
||||
) => {
|
||||
let myNodeNum = 0;
|
||||
|
||||
@@ -51,6 +54,7 @@ export const subscribeAll = (
|
||||
|
||||
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||
device.setHardware(nodeInfo);
|
||||
messageStore.setNodeNum(nodeInfo.myNodeNum);
|
||||
myNodeNum = nodeInfo.myNodeNum;
|
||||
});
|
||||
|
||||
@@ -81,10 +85,10 @@ export const subscribeAll = (
|
||||
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
device.addMessage({
|
||||
...messagePacket,
|
||||
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
|
||||
});
|
||||
// incoming and outgoing messages are handled by this event listener
|
||||
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
|
||||
const message = dto.toMessage();
|
||||
messageStore.saveMessage(message);
|
||||
});
|
||||
|
||||
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
|
||||
@@ -105,9 +109,6 @@ export const subscribeAll = (
|
||||
});
|
||||
});
|
||||
|
||||
connection.events.onQueueStatus.subscribe((queueStatus) => {
|
||||
device.setQueueStatus(queueStatus);
|
||||
});
|
||||
|
||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||
if (routingPacket.data.variant.case === "errorReason") {
|
||||
|
||||
+37
-33
@@ -1,4 +1,3 @@
|
||||
import { useAppStore } from "../core/stores/appStore.ts";
|
||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
@@ -14,11 +13,14 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
export const MessagesPage = () => {
|
||||
const { channels, nodes, hardware, messages, hasNodeError } = useDevice();
|
||||
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
|
||||
const { channels, nodes, hardware, hasNodeError } = useDevice();
|
||||
const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
|
||||
const filteredNodes = Array.from(nodes.values()).filter((node) => {
|
||||
if (node.num === hardware.myNodeNum) return false;
|
||||
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
|
||||
@@ -29,14 +31,15 @@ export const MessagesPage = () => {
|
||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||
);
|
||||
const currentChannel = channels.get(activeChat);
|
||||
const { toast } = useToast();
|
||||
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;
|
||||
const otherNode = nodes.get(activeChat);
|
||||
|
||||
const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
|
||||
|
||||
const isDirect = chatType === MessageType.Direct;
|
||||
const isBroadcast = chatType === MessageType.Broadcast;
|
||||
|
||||
const currentChat = { type: chatType, id: activeChat };
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,9 +53,8 @@ export const MessagesPage = () => {
|
||||
: channel.index === 0
|
||||
? "Primary"
|
||||
: `Ch ${channel.index}`}
|
||||
active={activeChat === channel.index && chatType === "broadcast"}
|
||||
onClick={() => {
|
||||
setChatType("broadcast");
|
||||
setChatType(MessageType.Broadcast);
|
||||
setActiveChat(channel.index);
|
||||
}}
|
||||
element={<HashIcon size={16} className="mr-2" />}
|
||||
@@ -70,21 +72,21 @@ export const MessagesPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{filteredNodes.map((node) => (
|
||||
{filteredNodes.map((otherNode) => (
|
||||
<SidebarButton
|
||||
key={node.num}
|
||||
label={node.user?.longName ??
|
||||
`!${numberToHexUnpadded(node.num)}`}
|
||||
active={activeChat === node.num && chatType === "direct"}
|
||||
key={otherNode.num}
|
||||
label={otherNode.user?.longName ??
|
||||
`!${numberToHexUnpadded(otherNode.num)}`}
|
||||
active={activeChat === otherNode.num && chatType === "direct"}
|
||||
onClick={() => {
|
||||
setChatType("direct");
|
||||
setActiveChat(node.num);
|
||||
setChatType(MessageType.Direct);
|
||||
setActiveChat(otherNode.num);
|
||||
}}
|
||||
element={
|
||||
<Avatar
|
||||
text={node.user?.shortName ?? node.num.toString()}
|
||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
||||
showError={hasNodeError(node.num)}
|
||||
text={otherNode?.user?.shortName ?? otherNode.num.toString()}
|
||||
className={cn(hasNodeError(otherNode?.num) && "text-red-500")}
|
||||
showError={hasNodeError(otherNode?.num)}
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
@@ -96,13 +98,13 @@ export const MessagesPage = () => {
|
||||
<div className="flex flex-col w-full h-full container mx-auto">
|
||||
<PageLayout
|
||||
className="flex flex-col h-full"
|
||||
label={`Messages: ${chatType === "broadcast" && currentChannel
|
||||
label={`Messages: ${MessageType.Broadcast && currentChannel
|
||||
? getChannelName(currentChannel)
|
||||
: chatType === "direct" && nodes.get(activeChat)
|
||||
: chatType === MessageType.Direct && nodes.get(activeChat)
|
||||
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
|
||||
: "Loading..."
|
||||
}`}
|
||||
actions={chatType === "direct"
|
||||
actions={chatType === MessageType.Direct
|
||||
? [
|
||||
{
|
||||
icon: nodes.get(activeChat)?.user?.publicKey.length
|
||||
@@ -125,23 +127,24 @@ export const MessagesPage = () => {
|
||||
: []}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{chatType === "broadcast" && currentChannel && (
|
||||
{isBroadcast && currentChannel && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChannelChat
|
||||
key={currentChannel.index}
|
||||
messages={messages.broadcast.get(currentChannel.index)}
|
||||
messages={getMessages(MessageType.Broadcast, {
|
||||
myNodeNum: getNodeNum(),
|
||||
channel: currentChannel?.index
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatType === "direct" && node && (
|
||||
{isDirect && otherNode && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ChannelChat
|
||||
key={node.num}
|
||||
messages={messages.direct.get(node.num)}
|
||||
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,8 +153,9 @@ export const MessagesPage = () => {
|
||||
|
||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||
<MessageInput
|
||||
to={messageDestination}
|
||||
channel={messageChannel}
|
||||
from={activeChat}
|
||||
to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast}
|
||||
channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id}
|
||||
maxBytes={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { expect, afterEach } from 'vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||
import { enableMapSet } from "immer";
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
// Enable auto mocks for our UI components
|
||||
//vi.mock('@components/UI/Dialog.tsx');
|
||||
//vi.mock('@components/UI/Typography/Link.tsx');
|
||||
enableMapSet();
|
||||
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() { }
|
||||
|
||||
@@ -2,6 +2,8 @@ import path from "node:path";
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
import { enableMapSet } from "immer";
|
||||
enableMapSet();
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
|
||||
Reference in New Issue
Block a user