You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
152 lines
5.5 KiB
152 lines
5.5 KiB
import {
|
|
Tooltip,
|
|
TooltipArrow,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@components/UI/Tooltip.tsx";
|
|
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
|
import { cn } from "@core/utils/cn.ts";
|
|
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 { Protobuf } from "@meshtastic/js";
|
|
|
|
interface MessageProps {
|
|
lastMsgSameUser: boolean;
|
|
message: Message;
|
|
}
|
|
|
|
interface MessageStatus {
|
|
state: MessageState;
|
|
displayText: string;
|
|
icon: LucideIcon;
|
|
}
|
|
|
|
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 },
|
|
};
|
|
|
|
const getMessageStatus = (state: MessageState): MessageStatus =>
|
|
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle };
|
|
|
|
|
|
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
<TooltipContent /* ...props... */ >
|
|
{status.displayText}
|
|
<TooltipArrow className="fill-slate-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 Icon = status.icon;
|
|
return (
|
|
<StatusTooltip status={status}>
|
|
<Icon className={iconClass} {...otherProps} color={isFailed ? "currentColor" : undefined} />
|
|
</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
|
|
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>
|
|
);
|
|
};
|
|
|
|
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
|
const myNodeNum = useMessageStore((state) => state.nodeNum);
|
|
|
|
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()) {
|
|
if (device.nodes.has(message.from)) {
|
|
return device.nodes.get(message.from) ?? null;
|
|
}
|
|
}
|
|
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 messageStatus = getMessageStatus(message.state);
|
|
|
|
|
|
return (
|
|
<div className={messageContainerClass}>
|
|
<div className={alignmentClass}>
|
|
|
|
{/* 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={cn(
|
|
bubbleBaseStyle,
|
|
isDeviceUser ? sentBubbleStyle : receivedBubbleStyle
|
|
)}>
|
|
<div className={cn("flex items-center gap-1.5 mt-1 self-end", timeStatusColor)}>
|
|
<TimeDisplay date={message.date} />
|
|
</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>
|
|
);
|
|
};
|
|
|
|
|