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 = ({ 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 })} diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 887789b2..26cd6720 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -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 = () => ( -
+
No Messages
@@ -41,7 +41,7 @@ export const ChannelChat = ({ if (!messages?.length) { return ( -
+
@@ -50,26 +50,23 @@ export const ChannelChat = ({ } return ( -
+
-
- {messages?.map((message, index) => ( - 0 && - messages[index - 1].from === message.from - } - /> - ))} -
+
+ {messages?.map((message) => { + return ( + + ); + })} +
-
); }; \ No newline at end of file diff --git a/src/components/PageComponents/Messages/MessageActionsMenu.tsx b/src/components/PageComponents/Messages/MessageActionsMenu.tsx new file mode 100644 index 00000000..d0843f32 --- /dev/null +++ b/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 ( +
e.stopPropagation()}> + + + + + + + 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() { }