Browse Source

Merge pull request #563 from danditomaso/issue-550-incorrect-text-in-dialog

Incorrect text in PKI dialog
pull/568/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
0828618c0d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 20
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx
  2. 10
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  3. 8
      src/components/Dialog/DialogManager.tsx
  4. 16
      src/components/Dialog/PkiRegenerateDialog.tsx
  5. 10
      src/components/PageComponents/Channel.tsx
  6. 5
      src/components/PageComponents/Config/Security/Security.tsx
  7. 35
      src/components/PageComponents/Messages/ChannelChat.tsx
  8. 91
      src/components/PageComponents/Messages/MessageActionsMenu.tsx
  9. 156
      src/components/PageComponents/Messages/MessageItem.tsx
  10. 7
      src/components/UI/Avatar.tsx
  11. 6
      src/core/stores/deviceStore.ts
  12. 16
      src/core/stores/messageStore.test.ts
  13. 20
      src/core/stores/messageStore.ts
  14. 2
      src/pages/Messages.tsx
  15. 12
      src/tests/setupTests.ts

20
src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx → src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx

@ -1,26 +1,26 @@
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";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
vi.mock('@core/stores/messageStore.ts', () => ({
useMessageStore: vi.fn(() => ({
clearAllMessages: vi.fn(),
deleteAllMessages: vi.fn(),
})),
}));
describe('ClearMessagesDialog', () => {
describe('DeleteMessagesDialog', () => {
const mockOnOpenChange = vi.fn();
const mockClearAllMessages = vi.fn();
beforeEach(() => {
vi.mocked(useMessageStore).mockReturnValue({ clearAllMessages: mockClearAllMessages });
vi.mocked(useMessageStore).mockReturnValue({ deleteAllMessages: mockClearAllMessages });
mockOnOpenChange.mockClear();
mockClearAllMessages.mockClear();
});
it('renders the dialog when open is true', () => {
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
render(<DeleteMessagesDialog 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();
@ -28,24 +28,24 @@ describe('ClearMessagesDialog', () => {
});
it('does not render the dialog when open is false', () => {
render(<ClearMessagesDialog open={false} onOpenChange={mockOnOpenChange} />);
render(<DeleteMessagesDialog 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} />);
render(<DeleteMessagesDialog 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} />);
render(<DeleteMessagesDialog 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} />);
it('calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked', () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);

10
src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx → src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx

@ -12,16 +12,16 @@ import {
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore.ts";
export interface ClearMessagesDialogProps {
export interface DeleteMessagesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ClearMessagesDialog = ({
export const DeleteMessagesDialog = ({
open,
onOpenChange,
}: ClearMessagesDialogProps) => {
const { clearAllMessages } = useMessageStore();
}: DeleteMessagesDialogProps) => {
const { deleteAllMessages } = useMessageStore();
const handleCloseDialog = () => {
onOpenChange(false);
};
@ -50,7 +50,7 @@ export const ClearMessagesDialog = ({
<Button
variant="destructive"
onClick={() => {
clearAllMessages();
deleteAllMessages();
handleCloseDialog();
}}
>

8
src/components/Dialog/DialogManager.tsx

@ -10,7 +10,7 @@ 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";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
export const DialogManager = () => {
@ -86,10 +86,10 @@ export const DialogManager = () => {
setDialogOpen("rebootOTA", open);
}}
/>
<ClearMessagesDialog
open={dialog.clearMessages}
<DeleteMessagesDialog
open={dialog.deleteMessages}
onOpenChange={(open) => {
setDialogOpen("clearMessages", open);
setDialogOpen("deleteMessages", open);
}}
/>
</>

16
src/components/Dialog/PkiRegenerateDialog.tsx

@ -10,12 +10,22 @@ import {
} from "@components/UI/Dialog.tsx";
export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
}
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const PkiRegenerateDialog = ({
text = {
title: "Regenerate Key Pair",
description: "Are you sure you want to regenerate key pair?",
button: "Regenerate",
},
open,
onOpenChange,
onSubmit,
@ -25,14 +35,14 @@ export const PkiRegenerateDialog = ({
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle>
<DialogTitle>{text?.title}</DialogTitle>
<DialogDescription>
Are you sure you want to regenerate key pair?
{text?.description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}>
Regenerate
{text?.button}
</Button>
</DialogFooter>
</DialogContent>

10
src/components/PageComponents/Channel.tsx

@ -1,4 +1,4 @@
import type { ChannelValidation } from "@app/validation/channel.tsx";
import type { ChannelValidation } from "@app/validation/channel.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts";
@ -97,7 +97,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: {
...channel?.settings,
psk: pass,
moduleSettings: {...channel?.settings?.moduleSettings,
moduleSettings: {
...channel?.settings?.moduleSettings,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
}
},
@ -206,6 +207,11 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
]}
/>
<PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Pre-Shared Key?",
description: "Are you sure you want to regenerate the pre-shared key?",
}}
open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()}

5
src/components/PageComponents/Config/Security/Security.tsx

@ -302,6 +302,11 @@ export const Security = () => {
]}
/>
<PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Key pair?",
description: "Are you sure you want to regenerate key pair?",
}}
open={state.privateKeyDialogOpen}
onOpenChange={() =>
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}

35
src/components/PageComponents/Messages/ChannelChat.tsx

@ -1,14 +1,14 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import type { Message as Message } from "@core/stores/messageStore.ts";
import type { Message as MessageType } from "@core/stores/messageStore.ts";
import { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
export interface ChannelChatProps {
messages?: Message[];
messages?: MessageType[];
}
const EmptyState = () => (
<div className="flex flex-col place-content-center place-items-center p-8 text-white">
<div className="flex flex-col place-content-center place-items-center p-8 text-gray-500 dark:text-gray-400">
<InboxIcon className="h-8 w-8 mb-2" />
<span className="text-sm">No Messages</span>
</div>
@ -41,7 +41,7 @@ export const ChannelChat = ({
if (!messages?.length) {
return (
<div className="flex flex-col h-full container mx-auto">
<div className="flex flex-col h-full">
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
@ -50,26 +50,23 @@ export const ChannelChat = ({
}
return (
<div className="flex flex-col h-full container mx-auto">
<div className="flex flex-col h-full">
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
className="flex-1 overflow-y-auto py-4"
>
<div className="flex flex-col gap-1.5 justify-end min-h-full">
{messages?.map((message, index) => (
<MessageItem
key={message.messageId + index}
message={message}
lastMsgSameUser={
index > 0 &&
messages[index - 1].from === message.from
}
/>
))}
<div ref={messagesEndRef} className="w-full" />
<div className="flex flex-col justify-end min-h-full space-y-4">
{messages?.map((message) => {
return (
<MessageItem
key={message?.messageId}
message={message}
/>
);
})}
<div ref={messagesEndRef} className="h-0 w-full" />
</div>
</div>
</div>
);
};

91
src/components/PageComponents/Messages/MessageActionsMenu.tsx

@ -0,0 +1,91 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { cn } from "@core/utils/cn.ts";
import { SmilePlus, Reply } from "lucide-react";
interface MessageActionsMenuProps {
onAddReaction?: () => void;
onReply?: () => void;
}
export const MessageActionsMenu = ({
onAddReaction,
onReply
}: MessageActionsMenuProps) => {
const hoverIconBarClass = cn(
"absolute top-2 right-4",
"flex items-center gap-x-1",
"bg-white dark:bg-zinc-800",
"border border-gray-200 dark:border-zinc-600",
"rounded-md shadow-sm p-1",
"opacity-0 group-hover:opacity-100",
"transition-opacity duration-100 ease-in-out",
"z-10"
);
const hoverIconButtonClass = cn(
"p-1 rounded",
"text-gray-500 dark:text-gray-400",
"hover:text-gray-700 dark:hover:text-gray-300",
"hover:bg-gray-100 dark:hover:bg-zinc-700",
"cursor-pointer"
);
const iconSizeClass = "size-4";
return (
<div className={cn(hoverIconBarClass)} onClick={(e) => e.stopPropagation()}>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Add Reaction"
onClick={(e) => {
e.stopPropagation()
if (onAddReaction) {
onAddReaction();
}
}}
className={hoverIconButtonClass}
>
<SmilePlus className={iconSizeClass} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
Add Reaction
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Reply"
onClick={(e) => {
e.stopPropagation()
if (onReply) {
onReply();
}
}}
className={hoverIconButtonClass}
>
<Reply className={iconSizeClass} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
Reply
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};

156
src/components/PageComponents/Messages/MessageItem.tsx

@ -11,85 +11,76 @@ 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, useMessageStore } from "@core/stores/messageStore.ts";
import { Message, MessageState } from "@core/stores/messageStore.ts";
import { Protobuf } from "@meshtastic/js";
import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx";
interface MessageProps {
lastMsgSameUser: boolean;
message: Message;
// locale?: string; // locale
}
interface MessageStatus {
state: MessageState;
displayText: string;
icon: LucideIcon;
ariaLabel: string;
}
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
[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 },
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered" },
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message" },
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed" },
};
const getMessageStatus = (state: MessageState): MessageStatus =>
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle };
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown" };
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
<TooltipProvider>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent /* ...props... */ >
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
{status.displayText}
<TooltipArrow className="fill-slate-800" />
<TooltipArrow className="fill-gray-800" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
const isFailed = status.state === MessageState.Failed;
const iconClass = cn("w-4 h-4 shrink-0", className);
const StatusIcon = ({ status, className }: { status: MessageStatus; className?: string }) => {
const Icon = status.icon;
const iconClass = cn("w-3.5 h-3.5 shrink-0", className);
return (
<StatusTooltip status={status}>
<Icon className={iconClass} {...otherProps} color={isFailed ? "currentColor" : undefined} />
<span aria-label={status.ariaLabel} role="img">
<Icon className={iconClass} aria-hidden="true" />
</span>
</StatusTooltip>
);
};
const getMessageTextStyles = (status: MessageState, isDeviceUser: boolean) => {
const isFailed = status === MessageState.Failed;
return cn(
"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: number; className?: string }) => {
const _date = new Date(date);
const locale = 'en-US'; // TODO: this should be dynamic based on user settings
const _date = useMemo(() => new Date(date), [date]);
const locale = 'en-US'; // TODO: Make dynamic
const formattedTime = useMemo(() => _date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }), [_date, locale]);
const fullDate = useMemo(() => _date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }), [_date, locale]);
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>
<time dateTime={_date.toISOString()} className={cn("text-xs", className)}>
<span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDate}</span>
</time>
);
};
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
const myNodeNum = useMessageStore((state) => state.nodeNum);
export const MessageItem = ({ message }: MessageProps) => {
const { getDevices } = useDeviceStore();
const isDeviceUser = message.from === myNodeNum;
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
if (message?.from === null || message?.from === undefined) return null;
for (const device of getDevices()) {
const devices = getDevices();
for (const device of devices) {
if (device.nodes.has(message.from)) {
return device.nodes.get(message.from) ?? null;
}
@ -97,56 +88,63 @@ export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
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 { shortName, displayName } = useMemo(() => {
const fallbackName = message.from
const longName = messageUser?.user?.longName;
const shortName = messageUser?.user?.shortName ?? fallbackName;
const displayName = longName || fallbackName;
return { shortName, displayName };
}, [messageUser, message.from]);
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 messageStatus = getMessageStatus(message.state);
const messageText = message?.message ?? "";
const messageDate = message?.date;
const isFailed = message.state === MessageState.Failed;
const messageItemWrapperClass = cn(
"group w-full px-4 py-2 relative list-none",
"rounded-md",
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20",
"transition-colors duration-100 ease-in-out",
);
const 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 avatarSizeClass = "size-11";
const gridGapClass = "gap-x-4";
const baseTextStyle = "text-sm text-gray-800 dark:text-gray-200";
const nameTextStyle = "font-medium text-gray-900 dark:text-gray-100 mr-2";
const dateTextStyle = "text-gray-500 dark:text-gray-400";
const statusIconBaseColor = "text-gray-400 dark:text-gray-500";
const statusIconFailedColor = "text-red-500 dark:text-red-400";
return (
<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} />
<li className={messageItemWrapperClass}>
<div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}>
<Avatar size="sm" text={shortName} className={cn(avatarSizeClass, "pt-0.5")} />
<div className="flex flex-col gap-1.5 min-w-0">
{messageDate != null ? (
<div className="flex items-center gap-1.5">
<span className={nameTextStyle} aria-hidden="true">
{displayName}
</span>
<TimeDisplay date={messageDate} className={dateTextStyle} />
<StatusIcon
status={messageStatus}
className={cn(isFailed ? statusIconFailedColor : statusIconBaseColor)}
/>
</div>
) : null}
<div className={cn(baseTextStyle, "whitespace-pre-wrap")}>
{messageText}
</div>
<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>
<MessageActionsMenu
onReply={() => console.log("Reply to message:", message.messageId)}
/>
</li>
);
};
};

7
src/components/UI/Avatar.tsx

@ -10,7 +10,7 @@ type RGBColor = {
};
interface AvatarProps {
text: string;
text: string | number;
size?: "sm" | "lg";
className?: string;
showError?: boolean;
@ -68,10 +68,11 @@ export const Avatar: React.FC<AvatarProps> = ({
};
};
const bgColor = getColorFromText(text ?? "UNK");
const safeText = text?.toString().toUpperCase() ?? "UNK";
const bgColor = getColorFromText(safeText);
const isLight = ColorUtils.isLight(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = text?.toUpperCase().slice(0, 4) ?? "UNK";
const initials = safeText.slice(0, 4) ?? "UNK";
return (
<div

6
src/core/stores/deviceStore.ts

@ -30,7 +30,7 @@ export type DialogVariant =
| "nodeDetails"
| "unsafeRoles"
| "refreshKeys"
| "clearMessages";
| "deleteMessages";
type NodeError = {
node: number;
@ -73,7 +73,7 @@ export interface Device {
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
clearMessages: boolean;
deleteMessages: boolean;
};
@ -155,7 +155,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
unsafeRoles: false,
refreshKeys: false,
rebootOTA: false,
clearMessages: false,
deleteMessages: false,
},
pendingSettingsChanges: false,
messageDraft: "",

16
src/core/stores/messageStore.test.ts

@ -286,8 +286,8 @@ describe('useMessageStore', () => {
const messageIdToDelete = directMessageToOther1.messageId;
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
sender: myNodeNum,
recipient: otherNodeNum1,
from: myNodeNum,
to: otherNodeNum1,
messageId: messageIdToDelete
});
const state = useMessageStore.getState();
@ -300,8 +300,8 @@ describe('useMessageStore', () => {
const messageIdToDelete = directMessageFromOther1.messageId;
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
sender: otherNodeNum1,
recipient: myNodeNum,
from: otherNodeNum1,
to: myNodeNum,
messageId: messageIdToDelete
});
const state = useMessageStore.getState();
@ -321,8 +321,8 @@ describe('useMessageStore', () => {
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 });
it('should clean up empty to/from/channel objects', () => {
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, from: otherNodeNum1, to: 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
@ -354,14 +354,14 @@ describe('useMessageStore', () => {
});
});
describe('clearAllMessages', () => {
describe('deleteAllMessages', () => {
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();
useMessageStore.getState().deleteAllMessages();
expect(useMessageStore.getState().messages.direct).toEqual({});
expect(useMessageStore.getState().messages.broadcast).toEqual({});

20
src/core/stores/messageStore.ts

@ -57,11 +57,11 @@ export interface MessageStore {
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;
deleteAllMessages: () => void;
clearMessageByMessageId: (params: {
type: MessageType;
sender?: number;
recipient?: number;
from?: number;
to?: number;
channel?: number;
messageId: number
}) => void;
@ -177,7 +177,7 @@ export const useMessageStore = create<MessageStore>()(
}
return [];
},
clearMessageByMessageId: ({ type, sender, recipient, channel, messageId }) => {
clearMessageByMessageId: ({ type, from, to, channel, messageId }) => {
set(produce((state: MessageStore) => {
if (type === MessageType.Broadcast && channel !== undefined) {
const messageMap = state.messages.broadcast[channel];
@ -187,14 +187,14 @@ export const useMessageStore = create<MessageStore>()(
delete state.messages.broadcast[channel];
}
}
} else if (type === MessageType.Direct && sender !== undefined && recipient !== undefined) {
const messageMap = state.messages.direct?.[sender]?.[recipient];
} else if (type === MessageType.Direct && from !== undefined && to !== undefined) {
const messageMap = state.messages.direct?.[from]?.[to];
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];
delete state.messages.direct[from][to];
if (Object.keys(state.messages.direct[from]).length === 0) {
delete state.messages.direct[from];
}
}
}
@ -215,7 +215,7 @@ export const useMessageStore = create<MessageStore>()(
state.draft.delete(key);
}));
},
clearAllMessages: () => {
deleteAllMessages: () => {
set(produce((state: MessageStore) => {
state.messages.direct = {};
state.messages.broadcast = {};

2
src/pages/Messages.tsx

@ -104,7 +104,7 @@ export const MessagesPage = () => {
</div>
</SidebarSection>
</Sidebar>
<div className="flex flex-col w-full h-full container mx-auto">
<div className="flex flex-col w-full h-full">
<PageLayout
className="flex flex-col h-full"
label={`Messages: ${isBroadcast && currentChannel

12
src/tests/setupTests.ts

@ -1,10 +1,20 @@
import { afterEach } from 'vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
import { enableMapSet } from "immer";
import "@testing-library/jest-dom";
import '@testing-library/user-event';
enableMapSet();
vi.mock('idb-keyval', () => ({
get: vi.fn((key) => Promise.resolve(undefined)),
set: vi.fn((key, value) => Promise.resolve()),
del: vi.fn((key) => Promise.resolve()),
clear: vi.fn(() => Promise.resolve()),
keys: vi.fn(() => Promise.resolve([])),
createStore: vi.fn((dbName, storeName) => ({
})),
}));
globalThis.ResizeObserver = class {
observe() { }
unobserve() { }

Loading…
Cancel
Save