renamed dialog, added reactions menu
This commit is contained in:
+10
-10
@@ -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);
|
||||||
+5
-5
@@ -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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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 { 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;
|
||||||
const messageContainerClass = cn(
|
return { shortName, displayName };
|
||||||
"flex flex-col w-full px-4 justify-start",
|
}, [messageUser, message.from]);
|
||||||
!lastMsgSameUser ? "pt-3" : "pt-0.5"
|
|
||||||
);
|
|
||||||
const alignmentClass = cn(
|
|
||||||
"flex flex-col flex-wrap w-full",
|
|
||||||
isDeviceUser ? "items-end" : "items-start"
|
|
||||||
);
|
|
||||||
const bubbleBaseStyle = "flex flex-col max-w-[75%] rounded-lg px-3 py-1.5 text-sm shadow-md";
|
|
||||||
const sentBubbleStyle = "bg-gradient-to-br from-blue-600 to-blue-700 dark:from-blue-500 dark:to-blue-600 text-white";
|
|
||||||
const receivedBubbleStyle = "bg-slate-200 dark:bg-slate-500 text-slate-900 dark:text-white";
|
|
||||||
const timeStatusColor = isDeviceUser ? "text-blue-100 dark:text-blue-200" : "text-slate-500 dark:text-slate-300";
|
|
||||||
|
|
||||||
const messageStatus = getMessageStatus(message.state);
|
const 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 (
|
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 */}
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
{!lastMsgSameUser && (
|
{messageDate != null ? (
|
||||||
<div className="flex items-center gap-1.5 mb-1 px-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<Avatar text={shortName} />
|
<span className={nameTextStyle} aria-hidden="true">
|
||||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 truncate">
|
{displayName}
|
||||||
{displayName}
|
</span>
|
||||||
</span>
|
<TimeDisplay date={messageDate} className={dateTextStyle} />
|
||||||
</div>
|
<StatusIcon
|
||||||
)}
|
status={messageStatus}
|
||||||
|
className={cn(isFailed ? statusIconFailedColor : statusIconBaseColor)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(baseTextStyle, "whitespace-pre-wrap")}>
|
||||||
bubbleBaseStyle,
|
{messageText}
|
||||||
isDeviceUser ? sentBubbleStyle : receivedBubbleStyle
|
|
||||||
)}>
|
|
||||||
<div className={cn("flex items-center gap-1.5 mt-1 self-end", timeStatusColor)}>
|
|
||||||
<TimeDisplay date={message.date} />
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+11
-1
@@ -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() { }
|
||||||
|
|||||||
Reference in New Issue
Block a user