renamed dialog, added reactions menu

This commit is contained in:
Dan Ditomaso
2025-04-07 11:42:15 -04:00
parent e410ccb2f4
commit 0e868cef58
12 changed files with 239 additions and 142 deletions
@@ -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);
@@ -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();
}}
>
+4 -4
View File
@@ -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);
}}
/>
</>
@@ -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>
);
};
};
+4 -3
View File
@@ -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