From 0e868cef585975513b2380fb648665700cb7a52d Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 7 Apr 2025 11:42:15 -0400 Subject: [PATCH 1/2] renamed dialog, added reactions menu --- .../DeleteMessagesDialog.test.tsx} | 20 +-- .../DeleteMessagesDialog.tsx} | 10 +- src/components/Dialog/DialogManager.tsx | 8 +- .../PageComponents/Messages/ChannelChat.tsx | 35 ++-- .../Messages/MessageActionsMenu.tsx | 91 ++++++++++ .../PageComponents/Messages/MessageItem.tsx | 156 +++++++++--------- src/components/UI/Avatar.tsx | 7 +- src/core/stores/deviceStore.ts | 6 +- src/core/stores/messageStore.test.ts | 16 +- src/core/stores/messageStore.ts | 20 +-- src/pages/Messages.tsx | 2 +- src/tests/setupTests.ts | 12 +- 12 files changed, 240 insertions(+), 143 deletions(-) rename src/components/Dialog/{ClearMessagesDialog/ClearMessagesDialog.test.tsx => DeleteMessagesDialog/DeleteMessagesDialog.test.tsx} (67%) rename src/components/Dialog/{ClearMessagesDialog/ClearMessagesDialog.tsx => DeleteMessagesDialog/DeleteMessagesDialog.tsx} (87%) create mode 100644 src/components/PageComponents/Messages/MessageActionsMenu.tsx diff --git a/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx similarity index 67% rename from src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx rename to src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx index cf317589..17e48eb1 100644 --- a/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.test.tsx +++ b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx @@ -1,26 +1,26 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useMessageStore } from "@core/stores/messageStore.ts"; -import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx"; +import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; vi.mock('@core/stores/messageStore.ts', () => ({ useMessageStore: vi.fn(() => ({ - clearAllMessages: vi.fn(), + deleteAllMessages: vi.fn(), })), })); -describe('ClearMessagesDialog', () => { +describe('DeleteMessagesDialog', () => { const mockOnOpenChange = vi.fn(); const mockClearAllMessages = vi.fn(); beforeEach(() => { - vi.mocked(useMessageStore).mockReturnValue({ clearAllMessages: mockClearAllMessages }); + vi.mocked(useMessageStore).mockReturnValue({ deleteAllMessages: mockClearAllMessages }); mockOnOpenChange.mockClear(); mockClearAllMessages.mockClear(); }); it('renders the dialog when open is true', () => { - render(); + render(); 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(); + render(); expect(screen.queryByText('Clear All Messages')).toBeNull(); }); it('calls onOpenChange with false when the close button is clicked', () => { - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'Close' })); expect(mockOnOpenChange).toHaveBeenCalledWith(false); }); it('calls onOpenChange with false when the dismiss button is clicked', () => { - render(); + render(); 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(); + it('calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked', () => { + render(); fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); expect(mockClearAllMessages).toHaveBeenCalledTimes(1); expect(mockOnOpenChange).toHaveBeenCalledWith(false); diff --git a/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx similarity index 87% rename from src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx rename to src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx index 4bb27b82..c93f2795 100644 --- a/src/components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx +++ b/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx @@ -12,16 +12,16 @@ import { import { AlertTriangleIcon } from "lucide-react"; import { useMessageStore } from "@core/stores/messageStore.ts"; -export interface ClearMessagesDialogProps { +export interface DeleteMessagesDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } -export const ClearMessagesDialog = ({ +export const DeleteMessagesDialog = ({ open, onOpenChange, -}: ClearMessagesDialogProps) => { - const { clearAllMessages } = useMessageStore(); +}: DeleteMessagesDialogProps) => { + const { deleteAllMessages } = useMessageStore(); const handleCloseDialog = () => { onOpenChange(false); }; @@ -50,7 +50,7 @@ export const ClearMessagesDialog = ({ + + + Add Reaction + + + + + + + + + + Reply + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index f448bb01..6c784cd7 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -11,85 +11,76 @@ import { Avatar } from "@components/UI/Avatar.tsx"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { ReactNode, useMemo } from "react"; -import { Message, MessageState, useMessageStore } from "@core/stores/messageStore.ts"; +import { Message, MessageState } from "@core/stores/messageStore.ts"; import { Protobuf } from "@meshtastic/js"; +import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; interface MessageProps { - lastMsgSameUser: boolean; message: Message; + // locale?: string; // locale } interface MessageStatus { state: MessageState; displayText: string; icon: LucideIcon; + ariaLabel: string; } const MESSAGE_STATUS: Record = { - [MessageState.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 }) => ( - + {children} - + {status.displayText} - + ); -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 ( - + + ); }; -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 ( -
- - {_date?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true })} - - {/* TODO: Conditionally show date for older messages? */} -
+ ); }; -export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => { - const myNodeNum = useMessageStore((state) => state.nodeNum); - +export const MessageItem = ({ message }: MessageProps) => { const { getDevices } = useDeviceStore(); - const isDeviceUser = message.from === myNodeNum; - const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => { if (message?.from === null || message?.from === undefined) return null; - for (const device of getDevices()) { + const devices = getDevices(); + for (const device of devices) { if (device.nodes.has(message.from)) { return device.nodes.get(message.from) ?? null; } @@ -97,56 +88,63 @@ export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => { return null; }, [getDevices, message.from]); - const fallbackName = `${message.from}`; - const longName = messageUser?.user?.longName; - const shortName = messageUser?.user?.shortName ?? fallbackName.slice(0, 2).toUpperCase(); - const displayName = isDeviceUser ? "You" : (longName || fallbackName); + const { shortName, displayName } = useMemo(() => { + const fallbackName = message.from + const longName = messageUser?.user?.longName; + const shortName = messageUser?.user?.shortName ?? fallbackName; + const displayName = longName || fallbackName; + return { shortName, displayName }; + }, [messageUser, message.from]); - const messageContainerClass = cn( - "flex flex-col w-full px-4 justify-start", - !lastMsgSameUser ? "pt-3" : "pt-0.5" - ); - const alignmentClass = cn( - "flex flex-col flex-wrap w-full", - isDeviceUser ? "items-end" : "items-start" + const messageStatus = getMessageStatus(message.state); + const messageText = message?.message ?? ""; + const messageDate = message?.date; + const isFailed = message.state === MessageState.Failed; + + const messageItemWrapperClass = cn( + "group w-full px-4 py-2 relative list-none", + "rounded-md", + "hover:bg-slate-300/15 dark:hover:bg-slate-600/20", + "transition-colors duration-100 ease-in-out", ); - const bubbleBaseStyle = "flex flex-col max-w-[75%] rounded-lg px-3 py-1.5 text-sm shadow-md"; - const sentBubbleStyle = "bg-gradient-to-br from-blue-600 to-blue-700 dark:from-blue-500 dark:to-blue-600 text-white"; - const receivedBubbleStyle = "bg-slate-200 dark:bg-slate-500 text-slate-900 dark:text-white"; - const timeStatusColor = isDeviceUser ? "text-blue-100 dark:text-blue-200" : "text-slate-500 dark:text-slate-300"; - const messageStatus = getMessageStatus(message.state); + const avatarSizeClass = "size-11"; + const gridGapClass = "gap-x-4"; + const baseTextStyle = "text-sm text-gray-800 dark:text-gray-200"; + const nameTextStyle = "font-medium text-gray-900 dark:text-gray-100 mr-2"; + const dateTextStyle = "text-gray-500 dark:text-gray-400"; + const statusIconBaseColor = "text-gray-400 dark:text-gray-500"; + const statusIconFailedColor = "text-red-500 dark:text-red-400"; return ( -
-
- - {/* Show only if not consecutive message AND not sent by self */} - {!lastMsgSameUser && ( -
- - - {displayName} - -
- )} - -
-
- +
  • +
    + + +
    + {messageDate != null ? ( +
    + + + +
    + ) : null} + +
    + {messageText}
    -
    - {message.message || Empty message} - {isDeviceUser && } -
    -
  • + console.log("Reply to message:", message.messageId)} + /> + ); -}; - +}; \ No newline at end of file diff --git a/src/components/UI/Avatar.tsx b/src/components/UI/Avatar.tsx index 484aa2c3..5ea1d3ed 100644 --- a/src/components/UI/Avatar.tsx +++ b/src/components/UI/Avatar.tsx @@ -10,7 +10,7 @@ type RGBColor = { }; interface AvatarProps { - text: string; + text: string | number; size?: "sm" | "lg"; className?: string; showError?: boolean; @@ -68,10 +68,11 @@ export const Avatar: React.FC = ({ }; }; - 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 (
    ((set, get) => ({ unsafeRoles: false, refreshKeys: false, rebootOTA: false, - clearMessages: false, + deleteMessages: false, }, pendingSettingsChanges: false, messageDraft: "", diff --git a/src/core/stores/messageStore.test.ts b/src/core/stores/messageStore.test.ts index a8dc9880..ec3e1e59 100644 --- a/src/core/stores/messageStore.test.ts +++ b/src/core/stores/messageStore.test.ts @@ -286,8 +286,8 @@ describe('useMessageStore', () => { const messageIdToDelete = directMessageToOther1.messageId; useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, - sender: myNodeNum, - recipient: otherNodeNum1, + from: myNodeNum, + to: otherNodeNum1, messageId: messageIdToDelete }); const state = useMessageStore.getState(); @@ -300,8 +300,8 @@ describe('useMessageStore', () => { const messageIdToDelete = directMessageFromOther1.messageId; useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, - sender: otherNodeNum1, - recipient: myNodeNum, + from: otherNodeNum1, + to: myNodeNum, messageId: messageIdToDelete }); const state = useMessageStore.getState(); @@ -321,8 +321,8 @@ describe('useMessageStore', () => { expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined(); }); - it('should clean up empty recipient/sender/channel objects', () => { - useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, sender: otherNodeNum1, recipient: myNodeNum, messageId: directMessageFromOther1.messageId }); + it('should clean up empty to/from/channel objects', () => { + useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, from: otherNodeNum1, to: myNodeNum, messageId: directMessageFromOther1.messageId }); expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed @@ -354,14 +354,14 @@ describe('useMessageStore', () => { }); }); - describe('clearAllMessages', () => { + describe('deleteAllMessages', () => { it('should clear all direct and broadcast messages', () => { useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(broadcastMessage1); expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0); expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0); - useMessageStore.getState().clearAllMessages(); + useMessageStore.getState().deleteAllMessages(); expect(useMessageStore.getState().messages.direct).toEqual({}); expect(useMessageStore.getState().messages.broadcast).toEqual({}); diff --git a/src/core/stores/messageStore.ts b/src/core/stores/messageStore.ts index fdb9ad9c..b198e382 100644 --- a/src/core/stores/messageStore.ts +++ b/src/core/stores/messageStore.ts @@ -57,11 +57,11 @@ export interface MessageStore { getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[]; getDraft: (key: Types.Destination) => string; setDraft: (key: Types.Destination, message: string) => void; - clearAllMessages: () => void; + deleteAllMessages: () => void; clearMessageByMessageId: (params: { type: MessageType; - sender?: number; - recipient?: number; + from?: number; + to?: number; channel?: number; messageId: number }) => void; @@ -177,7 +177,7 @@ export const useMessageStore = create()( } 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()( 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()( state.draft.delete(key); })); }, - clearAllMessages: () => { + deleteAllMessages: () => { set(produce((state: MessageStore) => { state.messages.direct = {}; state.messages.broadcast = {}; diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 70d33c60..30afe803 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -104,7 +104,7 @@ export const MessagesPage = () => {
    -
    +
    ({ + 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() { } From 7267101021559a0d07c7b105d4c756fa1d7481ce Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 7 Apr 2025 15:02:12 -0400 Subject: [PATCH 2/2] fix: extended dialog to allow for dynamic title/description --- src/components/Dialog/PkiRegenerateDialog.tsx | 16 +++++++++++++--- src/components/PageComponents/Channel.tsx | 10 ++++++++-- .../PageComponents/Config/Security/Security.tsx | 5 +++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/components/Dialog/PkiRegenerateDialog.tsx b/src/components/Dialog/PkiRegenerateDialog.tsx index c4f9e91e..b0a0df16 100644 --- a/src/components/Dialog/PkiRegenerateDialog.tsx +++ b/src/components/Dialog/PkiRegenerateDialog.tsx @@ -10,12 +10,22 @@ import { } from "@components/UI/Dialog.tsx"; export interface PkiRegenerateDialogProps { + text: { + title: string; + description: string; + button: string; + } open: boolean; onOpenChange: () => void; onSubmit: () => void; } export const PkiRegenerateDialog = ({ + text = { + title: "Regenerate Key Pair", + description: "Are you sure you want to regenerate key pair?", + button: "Regenerate", + }, open, onOpenChange, onSubmit, @@ -25,14 +35,14 @@ export const PkiRegenerateDialog = ({ - Regenerate Key pair? + {text?.title} - Are you sure you want to regenerate key pair? + {text?.description} diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index 1a523ca4..f9d8a35d 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -1,4 +1,4 @@ -import type { ChannelValidation } from "@app/validation/channel.tsx"; +import type { ChannelValidation } from "@app/validation/channel.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useToast } from "@core/hooks/useToast.ts"; @@ -97,7 +97,8 @@ export const Channel = ({ channel }: SettingsPanelProps) => { settings: { ...channel?.settings, psk: pass, - moduleSettings: {...channel?.settings?.moduleSettings, + moduleSettings: { + ...channel?.settings?.moduleSettings, positionPrecision: channel?.settings?.moduleSettings?.positionPrecision === undefined ? 10 : channel?.settings?.moduleSettings?.positionPrecision, } }, @@ -206,6 +207,11 @@ export const Channel = ({ channel }: SettingsPanelProps) => { ]} /> setPreSharedDialogOpen(false)} onSubmit={() => preSharedKeyRegenerate()} diff --git a/src/components/PageComponents/Config/Security/Security.tsx b/src/components/PageComponents/Config/Security/Security.tsx index 871e0384..68446e4d 100644 --- a/src/components/PageComponents/Config/Security/Security.tsx +++ b/src/components/PageComponents/Config/Security/Security.tsx @@ -302,6 +302,11 @@ export const Security = () => { ]} /> dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}