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 { render, screen, fireEvent } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useMessageStore } from "@core/stores/messageStore.ts"; 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', () => ({ vi.mock('@core/stores/messageStore.ts', () => ({
useMessageStore: vi.fn(() => ({ useMessageStore: vi.fn(() => ({
clearAllMessages: vi.fn(), deleteAllMessages: vi.fn(),
})), })),
})); }));
describe('ClearMessagesDialog', () => { describe('DeleteMessagesDialog', () => {
const mockOnOpenChange = vi.fn(); const mockOnOpenChange = vi.fn();
const mockClearAllMessages = vi.fn(); const mockClearAllMessages = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.mocked(useMessageStore).mockReturnValue({ clearAllMessages: mockClearAllMessages }); vi.mocked(useMessageStore).mockReturnValue({ deleteAllMessages: mockClearAllMessages });
mockOnOpenChange.mockClear(); mockOnOpenChange.mockClear();
mockClearAllMessages.mockClear(); mockClearAllMessages.mockClear();
}); });
it('renders the dialog when open is true', () => { 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('Clear All Messages')).toBeVisible();
expect(screen.getByText(/This action will clear all message history./)).toBeVisible(); expect(screen.getByText(/This action will clear all message history./)).toBeVisible();
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible();
@ -28,24 +28,24 @@ describe('ClearMessagesDialog', () => {
}); });
it('does not render the dialog when open is false', () => { 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(); expect(screen.queryByText('Clear All Messages')).toBeNull();
}); });
it('calls onOpenChange with false when the close button is clicked', () => { 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' })); fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(mockOnOpenChange).toHaveBeenCalledWith(false); expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
it('calls onOpenChange with false when the dismiss button is clicked', () => { 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' })); fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(mockOnOpenChange).toHaveBeenCalledWith(false); expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
it('calls clearAllMessages and onOpenChange with false when the clear messages button is clicked', () => { it('calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked', () => {
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1); expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false); 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 { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore.ts"; import { useMessageStore } from "@core/stores/messageStore.ts";
export interface ClearMessagesDialogProps { export interface DeleteMessagesDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export const ClearMessagesDialog = ({ export const DeleteMessagesDialog = ({
open, open,
onOpenChange, onOpenChange,
}: ClearMessagesDialogProps) => { }: DeleteMessagesDialogProps) => {
const { clearAllMessages } = useMessageStore(); const { deleteAllMessages } = useMessageStore();
const handleCloseDialog = () => { const handleCloseDialog = () => {
onOpenChange(false); onOpenChange(false);
}; };
@ -50,7 +50,7 @@ export const ClearMessagesDialog = ({
<Button <Button
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
clearAllMessages(); deleteAllMessages();
handleCloseDialog(); 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 { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx"; import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.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 = () => { export const DialogManager = () => {
@ -86,10 +86,10 @@ export const DialogManager = () => {
setDialogOpen("rebootOTA", open); setDialogOpen("rebootOTA", open);
}} }}
/> />
<ClearMessagesDialog <DeleteMessagesDialog
open={dialog.clearMessages} open={dialog.deleteMessages}
onOpenChange={(open) => { onOpenChange={(open) => {
setDialogOpen("clearMessages", open); setDialogOpen("deleteMessages", open);
}} }}
/> />
</> </>

16
src/components/Dialog/PkiRegenerateDialog.tsx

@ -10,12 +10,22 @@ import {
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
export interface PkiRegenerateDialogProps { export interface PkiRegenerateDialogProps {
text: {
title: string;
description: string;
button: string;
}
open: boolean; open: boolean;
onOpenChange: () => void; onOpenChange: () => void;
onSubmit: () => void; onSubmit: () => void;
} }
export const PkiRegenerateDialog = ({ export const PkiRegenerateDialog = ({
text = {
title: "Regenerate Key Pair",
description: "Are you sure you want to regenerate key pair?",
button: "Regenerate",
},
open, open,
onOpenChange, onOpenChange,
onSubmit, onSubmit,
@ -25,14 +35,14 @@ export const PkiRegenerateDialog = ({
<DialogContent> <DialogContent>
<DialogClose /> <DialogClose />
<DialogHeader> <DialogHeader>
<DialogTitle>Regenerate Key pair?</DialogTitle> <DialogTitle>{text?.title}</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to regenerate key pair? {text?.description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="destructive" onClick={() => onSubmit()}> <Button variant="destructive" onClick={() => onSubmit()}>
Regenerate {text?.button}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </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 { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
@ -97,7 +97,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
settings: { settings: {
...channel?.settings, ...channel?.settings,
psk: pass, psk: pass,
moduleSettings: {...channel?.settings?.moduleSettings, moduleSettings: {
...channel?.settings?.moduleSettings,
positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision, positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision,
} }
}, },
@ -206,6 +207,11 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
]} ]}
/> />
<PkiRegenerateDialog <PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Pre-Shared Key?",
description: "Are you sure you want to regenerate the pre-shared key?",
}}
open={preSharedDialogOpen} open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)} onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()} onSubmit={() => preSharedKeyRegenerate()}

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

@ -302,6 +302,11 @@ export const Security = () => {
]} ]}
/> />
<PkiRegenerateDialog <PkiRegenerateDialog
text={{
button: "Regenerate",
title: "Regenerate Key pair?",
description: "Are you sure you want to regenerate key pair?",
}}
open={state.privateKeyDialogOpen} open={state.privateKeyDialogOpen}
onOpenChange={() => onOpenChange={() =>
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })} 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 { 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 { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
export interface ChannelChatProps { export interface ChannelChatProps {
messages?: Message[]; messages?: MessageType[];
} }
const EmptyState = () => ( 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" /> <InboxIcon className="h-8 w-8 mb-2" />
<span className="text-sm">No Messages</span> <span className="text-sm">No Messages</span>
</div> </div>
@ -41,7 +41,7 @@ export const ChannelChat = ({
if (!messages?.length) { if (!messages?.length) {
return ( 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"> <div className="flex-1 flex items-center justify-center">
<EmptyState /> <EmptyState />
</div> </div>
@ -50,26 +50,23 @@ export const ChannelChat = ({
} }
return ( return (
<div className="flex flex-col h-full container mx-auto"> <div className="flex flex-col h-full">
<div <div
ref={scrollContainerRef} 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"> <div className="flex flex-col justify-end min-h-full space-y-4">
{messages?.map((message, index) => ( {messages?.map((message) => {
<MessageItem return (
key={message.messageId + index} <MessageItem
message={message} key={message?.messageId}
lastMsgSameUser={ message={message}
index > 0 && />
messages[index - 1].from === message.from );
} })}
/> <div ref={messagesEndRef} className="h-0 w-full" />
))}
<div ref={messagesEndRef} className="w-full" />
</div> </div>
</div> </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 { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "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 { Protobuf } from "@meshtastic/js";
import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx";
interface MessageProps { interface MessageProps {
lastMsgSameUser: boolean;
message: Message; message: Message;
// locale?: string; // locale
} }
interface MessageStatus { interface MessageStatus {
state: MessageState; state: MessageState;
displayText: string; displayText: string;
icon: LucideIcon; icon: LucideIcon;
ariaLabel: string;
} }
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = { const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2 }, [MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered" },
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis }, [MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message" },
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle }, [MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed" },
}; };
const getMessageStatus = (state: MessageState): MessageStatus => 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 }) => ( const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
<TooltipProvider> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent /* ...props... */ > <TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs">
{status.displayText} {status.displayText}
<TooltipArrow className="fill-slate-800" /> <TooltipArrow className="fill-gray-800" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => { const StatusIcon = ({ status, className }: { status: MessageStatus; className?: string }) => {
const isFailed = status.state === MessageState.Failed;
const iconClass = cn("w-4 h-4 shrink-0", className);
const Icon = status.icon; const Icon = status.icon;
const iconClass = cn("w-3.5 h-3.5 shrink-0", className);
return ( return (
<StatusTooltip status={status}> <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> </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 TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
const _date = new Date(date); const _date = useMemo(() => new Date(date), [date]);
const locale = 'en-US'; // TODO: this should be dynamic based on user settings 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 ( return (
<div className={cn("flex items-center gap-1 text-xs font-mono", className)}> <time dateTime={_date.toISOString()} className={cn("text-xs", className)}>
<span> <span aria-hidden="true">{formattedTime}</span>
{_date?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true })} <span className="sr-only">{fullDate}</span>
</span> </time>
{/* TODO: Conditionally show date for older messages? */}
</div>
); );
}; };
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => { export const MessageItem = ({ message }: MessageProps) => {
const myNodeNum = useMessageStore((state) => state.nodeNum);
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const isDeviceUser = message.from === myNodeNum;
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => { const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
if (message?.from === null || message?.from === undefined) return null; 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)) { if (device.nodes.has(message.from)) {
return device.nodes.get(message.from) ?? null; return device.nodes.get(message.from) ?? null;
} }
@ -97,56 +88,63 @@ export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
return null; return null;
}, [getDevices, message.from]); }, [getDevices, message.from]);
const fallbackName = `${message.from}`; const { shortName, displayName } = useMemo(() => {
const longName = messageUser?.user?.longName; const fallbackName = message.from
const shortName = messageUser?.user?.shortName ?? fallbackName.slice(0, 2).toUpperCase(); const longName = messageUser?.user?.longName;
const displayName = isDeviceUser ? "You" : (longName || fallbackName); const shortName = messageUser?.user?.shortName ?? fallbackName;
const displayName = longName || fallbackName;
return { shortName, displayName };
}, [messageUser, message.from]);
const messageContainerClass = cn( const messageStatus = getMessageStatus(message.state);
"flex flex-col w-full px-4 justify-start", const messageText = message?.message ?? "";
!lastMsgSameUser ? "pt-3" : "pt-0.5" const messageDate = message?.date;
); const isFailed = message.state === MessageState.Failed;
const alignmentClass = cn(
"flex flex-col flex-wrap w-full", const messageItemWrapperClass = cn(
isDeviceUser ? "items-end" : "items-start" "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 ( return (
<div className={messageContainerClass}> <li className={messageItemWrapperClass}>
<div className={alignmentClass}> <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 flex-col gap-1.5 min-w-0">
<div className="flex items-center gap-1.5 mb-1 px-1"> {messageDate != null ? (
<Avatar text={shortName} /> <div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 truncate"> <span className={nameTextStyle} aria-hidden="true">
{displayName} {displayName}
</span> </span>
</div> <TimeDisplay date={messageDate} className={dateTextStyle} />
)} <StatusIcon
status={messageStatus}
<div className={cn( className={cn(isFailed ? statusIconFailedColor : statusIconBaseColor)}
bubbleBaseStyle, />
isDeviceUser ? sentBubbleStyle : receivedBubbleStyle </div>
)}> ) : null}
<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>
<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> </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 { interface AvatarProps {
text: string; text: string | number;
size?: "sm" | "lg"; size?: "sm" | "lg";
className?: string; className?: string;
showError?: boolean; 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 isLight = ColorUtils.isLight(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF"; const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = text?.toUpperCase().slice(0, 4) ?? "UNK"; const initials = safeText.slice(0, 4) ?? "UNK";
return ( return (
<div <div

6
src/core/stores/deviceStore.ts

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

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

@ -286,8 +286,8 @@ describe('useMessageStore', () => {
const messageIdToDelete = directMessageToOther1.messageId; const messageIdToDelete = directMessageToOther1.messageId;
useMessageStore.getState().clearMessageByMessageId({ useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct, type: MessageType.Direct,
sender: myNodeNum, from: myNodeNum,
recipient: otherNodeNum1, to: otherNodeNum1,
messageId: messageIdToDelete messageId: messageIdToDelete
}); });
const state = useMessageStore.getState(); const state = useMessageStore.getState();
@ -300,8 +300,8 @@ describe('useMessageStore', () => {
const messageIdToDelete = directMessageFromOther1.messageId; const messageIdToDelete = directMessageFromOther1.messageId;
useMessageStore.getState().clearMessageByMessageId({ useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct, type: MessageType.Direct,
sender: otherNodeNum1, from: otherNodeNum1,
recipient: myNodeNum, to: myNodeNum,
messageId: messageIdToDelete messageId: messageIdToDelete
}); });
const state = useMessageStore.getState(); const state = useMessageStore.getState();
@ -321,8 +321,8 @@ describe('useMessageStore', () => {
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined(); expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined();
}); });
it('should clean up empty recipient/sender/channel objects', () => { it('should clean up empty to/from/channel objects', () => {
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, sender: otherNodeNum1, recipient: myNodeNum, messageId: directMessageFromOther1.messageId }); 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]?.[myNodeNum]).toBeUndefined(); // Recipient level removed
expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender 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', () => { it('should clear all direct and broadcast messages', () => {
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0); expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0);
expect(Object.keys(useMessageStore.getState().messages.broadcast).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.direct).toEqual({});
expect(useMessageStore.getState().messages.broadcast).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[]; getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[];
getDraft: (key: Types.Destination) => string; getDraft: (key: Types.Destination) => string;
setDraft: (key: Types.Destination, message: string) => void; setDraft: (key: Types.Destination, message: string) => void;
clearAllMessages: () => void; deleteAllMessages: () => void;
clearMessageByMessageId: (params: { clearMessageByMessageId: (params: {
type: MessageType; type: MessageType;
sender?: number; from?: number;
recipient?: number; to?: number;
channel?: number; channel?: number;
messageId: number messageId: number
}) => void; }) => void;
@ -177,7 +177,7 @@ export const useMessageStore = create<MessageStore>()(
} }
return []; return [];
}, },
clearMessageByMessageId: ({ type, sender, recipient, channel, messageId }) => { clearMessageByMessageId: ({ type, from, to, channel, messageId }) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
if (type === MessageType.Broadcast && channel !== undefined) { if (type === MessageType.Broadcast && channel !== undefined) {
const messageMap = state.messages.broadcast[channel]; const messageMap = state.messages.broadcast[channel];
@ -187,14 +187,14 @@ export const useMessageStore = create<MessageStore>()(
delete state.messages.broadcast[channel]; delete state.messages.broadcast[channel];
} }
} }
} else if (type === MessageType.Direct && sender !== undefined && recipient !== undefined) { } else if (type === MessageType.Direct && from !== undefined && to !== undefined) {
const messageMap = state.messages.direct?.[sender]?.[recipient]; const messageMap = state.messages.direct?.[from]?.[to];
if (messageMap?.[messageId]) { if (messageMap?.[messageId]) {
delete messageMap[messageId]; delete messageMap[messageId];
if (Object.keys(messageMap).length === 0) { if (Object.keys(messageMap).length === 0) {
delete state.messages.direct[sender][recipient]; delete state.messages.direct[from][to];
if (Object.keys(state.messages.direct[sender]).length === 0) { if (Object.keys(state.messages.direct[from]).length === 0) {
delete state.messages.direct[sender]; delete state.messages.direct[from];
} }
} }
} }
@ -215,7 +215,7 @@ export const useMessageStore = create<MessageStore>()(
state.draft.delete(key); state.draft.delete(key);
})); }));
}, },
clearAllMessages: () => { deleteAllMessages: () => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
state.messages.direct = {}; state.messages.direct = {};
state.messages.broadcast = {}; state.messages.broadcast = {};

2
src/pages/Messages.tsx

@ -104,7 +104,7 @@ export const MessagesPage = () => {
</div> </div>
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
<div className="flex flex-col w-full h-full container mx-auto"> <div className="flex flex-col w-full h-full">
<PageLayout <PageLayout
className="flex flex-col h-full" className="flex flex-col h-full"
label={`Messages: ${isBroadcast && currentChannel 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 { cleanup } from '@testing-library/react';
import { enableMapSet } from "immer"; import { enableMapSet } from "immer";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import '@testing-library/user-event';
enableMapSet(); 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 { globalThis.ResizeObserver = class {
observe() { } observe() { }
unobserve() { } unobserve() { }

Loading…
Cancel
Save