Merge pull request #563 from danditomaso/issue-550-incorrect-text-in-dialog
Incorrect text in PKI dialog
This commit is contained in:
+10
-10
@@ -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);
|
||||
+5
-5
@@ -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();
|
||||
}}
|
||||
>
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 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 { 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 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 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}>
|
||||
<li className={messageItemWrapperClass}>
|
||||
<div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}>
|
||||
<Avatar size="sm" text={shortName} className={cn(avatarSizeClass, "pt-0.5")} />
|
||||
|
||||
{/* 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="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(
|
||||
bubbleBaseStyle,
|
||||
isDeviceUser ? sentBubbleStyle : receivedBubbleStyle
|
||||
)}>
|
||||
<div className={cn("flex items-center gap-1.5 mt-1 self-end", timeStatusColor)}>
|
||||
<TimeDisplay date={message.date} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-1
@@ -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() { }
|
||||
|
||||
Reference in New Issue
Block a user