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.
209 lines
6.7 KiB
209 lines
6.7 KiB
import {
|
|
Tooltip,
|
|
TooltipArrow,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@components/UI/Tooltip.tsx";
|
|
import { useDevice } 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 {
|
|
MessageState,
|
|
useMessageStore,
|
|
} from "@core/stores/messageStore/index.ts";
|
|
import { Protobuf, Types } from "@meshtastic/core";
|
|
import { Message } from "@core/stores/messageStore/types.ts";
|
|
import { useTranslation } from "react-i18next";
|
|
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented
|
|
|
|
interface MessageStatusInfo {
|
|
displayText: string;
|
|
icon: LucideIcon;
|
|
ariaLabel: string;
|
|
iconClassName?: string;
|
|
}
|
|
|
|
const StatusTooltip = (
|
|
{ statusInfo, children }: {
|
|
statusInfo: MessageStatusInfo;
|
|
children: ReactNode;
|
|
},
|
|
) => (
|
|
<TooltipProvider delayDuration={300}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
|
|
{statusInfo.displayText}
|
|
<TooltipArrow className="fill-slate-800" />
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
|
|
interface MessageItemProps {
|
|
message: Message;
|
|
}
|
|
|
|
export const MessageItem = ({ message }: MessageItemProps) => {
|
|
const { getNode } = useDevice();
|
|
const { getMyNodeNum } = useMessageStore();
|
|
const { t, i18n } = useTranslation("messages");
|
|
|
|
const MESSAGE_STATUS_MAP = useMemo(
|
|
(): Record<MessageState, MessageStatusInfo> => ({
|
|
[MessageState.Ack]: {
|
|
displayText: t("deliveryStatus.delivered.displayText"),
|
|
icon: CheckCircle2,
|
|
ariaLabel: t("deliveryStatus.delivered.label"),
|
|
iconClassName: "text-green-500",
|
|
},
|
|
[MessageState.Waiting]: {
|
|
displayText: t("deliveryStatus.waiting.displayText"),
|
|
icon: CircleEllipsis,
|
|
ariaLabel: t("deliveryStatus.waiting.label"),
|
|
iconClassName: "text-slate-400",
|
|
},
|
|
[MessageState.Failed]: {
|
|
displayText: t("deliveryStatus.failed.displayText"),
|
|
icon: AlertCircle,
|
|
ariaLabel: t("deliveryStatus.failed.label"),
|
|
iconClassName: "text-red-500 dark:text-red-400",
|
|
},
|
|
}),
|
|
[t],
|
|
);
|
|
|
|
const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({
|
|
displayText: t("deliveryStatus.unknown.displayText"),
|
|
icon: AlertCircle,
|
|
ariaLabel: t("deliveryStatus.unknown.label"),
|
|
iconClassName: "text-red-500 dark:text-red-400",
|
|
}), [t]);
|
|
|
|
const getMessageStatusInfo = useMemo(
|
|
() => (state: MessageState): MessageStatusInfo =>
|
|
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS,
|
|
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
|
|
);
|
|
|
|
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
|
|
return message.from != null ? getNode(message.from) : null;
|
|
}, [getNode, message.from]);
|
|
|
|
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
|
|
|
const { displayName, shortName, isFavorite } = useMemo(() => {
|
|
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
|
const last4 = userIdHex.slice(-4);
|
|
const fallbackName = t("fallbackName", { last4 });
|
|
const longName = messageUser?.user?.longName;
|
|
const derivedShortName = messageUser?.user?.shortName || fallbackName;
|
|
const derivedDisplayName = longName || derivedShortName;
|
|
const isFavorite = messageUser?.num !== myNodeNum &&
|
|
messageUser?.isFavorite;
|
|
return {
|
|
displayName: derivedDisplayName,
|
|
shortName: derivedShortName,
|
|
isFavorite: isFavorite,
|
|
};
|
|
}, [messageUser, message.from, t, myNodeNum]);
|
|
|
|
const messageStatusInfo = getMessageStatusInfo(message.state);
|
|
const StatusIconComponent = messageStatusInfo.icon;
|
|
|
|
const messageDate = useMemo(
|
|
() => message.date ? new Date(message.date) : null,
|
|
[message.date],
|
|
);
|
|
const locale = i18n.language;
|
|
|
|
const formattedTime = useMemo(
|
|
() =>
|
|
messageDate?.toLocaleTimeString(locale, {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
}) ?? "",
|
|
[messageDate, locale],
|
|
);
|
|
|
|
const fullDateTime = useMemo(
|
|
() =>
|
|
messageDate?.toLocaleString(locale, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}) ?? "",
|
|
[messageDate, locale],
|
|
);
|
|
|
|
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
|
|
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
|
|
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
|
|
|
|
const messageItemWrapperClass = cn(
|
|
"group w-full 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 dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
|
|
|
|
return (
|
|
<li className={messageItemWrapperClass}>
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
|
<Avatar
|
|
size="sm"
|
|
text={shortName}
|
|
className="pt-0.5"
|
|
showFavorite={isFavorite}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-0.5 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate mr-1">
|
|
{displayName}
|
|
</span>
|
|
{messageDate && (
|
|
<time
|
|
dateTime={messageDate.toISOString()}
|
|
className={dateTextStyle}
|
|
>
|
|
<span aria-hidden="true">{formattedTime}</span>
|
|
<span className="sr-only">{fullDateTime}</span>
|
|
</time>
|
|
)}
|
|
{shouldShowStatusIcon && (
|
|
<StatusTooltip statusInfo={messageStatusInfo}>
|
|
<span aria-label={messageStatusInfo.ariaLabel} role="img">
|
|
<StatusIconComponent
|
|
className={cn(
|
|
"size-4 shrink-0",
|
|
messageStatusInfo.iconClassName,
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
</StatusTooltip>
|
|
)}
|
|
</div>
|
|
|
|
{message?.message && (
|
|
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words">
|
|
{message.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Actions Menu Placeholder */}
|
|
{
|
|
/* <div className="absolute top-1 right-1">
|
|
<MessageActionsMenu onReply={() => console.log("Reply")} />
|
|
</div> */
|
|
}
|
|
</li>
|
|
);
|
|
};
|
|
|