diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index b636fbc6..8fb8365f 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -1,74 +1,85 @@ -import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; import { type MessageWithState, useDevice, } from "@app/core/stores/deviceStore.ts"; import { Message } from "@components/PageComponents/Messages/Message.tsx"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; -import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; -import type { Protobuf, Types } from "@meshtastic/js"; +import type { Types } from "@meshtastic/js"; import { InboxIcon } from "lucide-react"; +import { useCallback, useEffect, useRef } from "react"; +import type { JSX } from "react"; export interface ChannelChatProps { messages?: MessageWithState[]; channel: Types.ChannelNumber; to: Types.Destination; - traceroutes?: Types.PacketMetadata[]; } +const EmptyState = () => ( +
+ + No Messages +
+); + export const ChannelChat = ({ messages, channel, to, - traceroutes, }: ChannelChatProps): JSX.Element => { const { nodes } = useDevice(); + const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); - return ( -
-
-
- {messages ? ( - messages.map((message, index) => ( - - )) - ) : ( -
- - No Messages -
- )} + const scrollToBottom = useCallback(() => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer) { + const isNearBottom = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight < + 100; + if (isNearBottom) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + } + }, []); + + useEffect(() => { + scrollToBottom(); + }, [scrollToBottom]); + + if (!messages?.length) { + return ( +
+
+
-
- {to === "broadcast" ? null : traceroutes ? ( - traceroutes.map((traceroute, index) => ( - - )) - ) : ( -
- - No Traceroutes -
- )} +
+ +
+
+ ); + } + + return ( +
+
+
+ {messages.map((message, index) => ( + 0 && messages[index - 1].from === message.from + } + sender={nodes.get(message.from)} + /> + ))} +
-
+
diff --git a/src/components/PageComponents/Messages/Message.tsx b/src/components/PageComponents/Messages/Message.tsx index e8030e2a..66268219 100644 --- a/src/components/PageComponents/Messages/Message.tsx +++ b/src/components/PageComponents/Messages/Message.tsx @@ -1,73 +1,154 @@ import type { MessageWithState } from "@app/core/stores/deviceStore.ts"; +import { cn } from "@app/core/utils/cn"; import { Avatar } from "@components/UI/Avatar"; import type { Protobuf } from "@meshtastic/js"; -import { - AlertCircleIcon, - CheckCircle2Icon, - CircleEllipsisIcon, -} from "lucide-react"; +import * as Tooltip from "@radix-ui/react-tooltip"; +import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; -export interface MessageProps { +const MESSAGE_STATES = { + ACK: "ack", + WAITING: "waiting", + FAILED: "failed", +} as const; + +type MessageState = MessageWithState["state"]; + +interface MessageProps { lastMsgSameUser: boolean; message: MessageWithState; sender?: Protobuf.Mesh.NodeInfo; } +interface StatusTooltipProps { + state: MessageState; + children: React.ReactNode; +} + +interface StatusIconProps { + state: MessageState; + className?: string; +} + +const STATUS_TEXT_MAP: Record = { + [MESSAGE_STATES.ACK]: "Message delivered", + [MESSAGE_STATES.WAITING]: "Waiting for delivery", + [MESSAGE_STATES.FAILED]: "Delivery failed", +} as const; + +const STATUS_ICON_MAP: Record = { + [MESSAGE_STATES.ACK]: CheckCircle2, + [MESSAGE_STATES.WAITING]: CircleEllipsis, + [MESSAGE_STATES.FAILED]: AlertCircle, +} as const; + +const getStatusText = (state: MessageState): string => STATUS_TEXT_MAP[state]; + +const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( + + + {children} + + + {getStatusText(state)} + + + + + +); + +const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { + const isFailed = state === MESSAGE_STATES.FAILED; + const iconClass = cn( + className, + "text-gray-500 dark:text-gray-400 w-4 h-4 flex-shrink-0", + ); + + const Icon = STATUS_ICON_MAP[state]; + return ( + + + + ); +}; + +const getMessageTextStyles = (state: MessageState) => { + const isAcknowledged = state === MESSAGE_STATES.ACK; + const isFailed = state === MESSAGE_STATES.FAILED; + const isWaiting = state === MESSAGE_STATES.WAITING; + + return cn( + "pl-2 break-words overflow-hidden", + isAcknowledged + ? "text-black dark:text-white" + : "text-black dark:text-gray-400", + isFailed && "text-red-500 dark:text-red-500", + ); +}; + +const TimeDisplay = ({ date }: { date: Date }) => ( +
+ + {date.toLocaleDateString()} + + + {date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + +
+); + export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => { - return lastMsgSameUser ? ( -
- {message.state === "ack" ? ( - - ) : message.state === "waiting" ? ( - - ) : ( - + const messageTextClass = getMessageTextStyles(message.state); + const isFailed = message.state === MESSAGE_STATES.ACK; + + const baseMessageWrapper = cn( + "flex items-center gap-2 w-full max-w-full pl-11", + !lastMsgSameUser && "flex-wrap flex-grow", + ); + + const containerClass = cn( + "w-full px-4 relative", + lastMsgSameUser ? "mt-1" : "mt-2", + !lastMsgSameUser && "pt-2", + ); + + return ( +
+ {!lastMsgSameUser && ( +
+
+ + + {sender?.user?.longName ?? "UNK"} + +
+ +
)} - - {message.data} - -
- ) : ( -
-
-
- +
+
+
{message.data}
- - {sender?.user?.longName ?? "UNK"} - - - {message.rxTime.toLocaleDateString()} - - - {message.rxTime.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })} - -
-
- {message.state === "ack" ? ( - - ) : message.state === "waiting" ? ( - - ) : ( - - )} - - {message.data} - +
); diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index b9080bb2..2e7954c9 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -4,7 +4,7 @@ import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import type { Types } from "@meshtastic/js"; import { SendIcon } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { type JSX, useCallback, useMemo, useState } from "react"; export interface MessageInputProps { to: Types.Destination; diff --git a/src/components/UI/Footer.tsx b/src/components/UI/Footer.tsx index d0e082e7..c50bb3e4 100644 --- a/src/components/UI/Footer.tsx +++ b/src/components/UI/Footer.tsx @@ -6,7 +6,7 @@ const Footer = React.forwardRef( ({ className, ...props }, ref) => { return (