|
|
@ -5,100 +5,89 @@ import { |
|
|
TooltipProvider, |
|
|
TooltipProvider, |
|
|
TooltipTrigger, |
|
|
TooltipTrigger, |
|
|
} from "@components/UI/Tooltip.tsx"; |
|
|
} from "@components/UI/Tooltip.tsx"; |
|
|
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts"; |
|
|
import { useDevice } from "@core/stores/deviceStore.ts"; |
|
|
import { cn } from "@core/utils/cn.ts"; |
|
|
import { cn } from "@core/utils/cn.ts"; |
|
|
import { Avatar } from "@components/UI/Avatar.tsx"; |
|
|
import { Avatar } from "@components/UI/Avatar.tsx"; |
|
|
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; |
|
|
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; |
|
|
import type { LucideIcon } from "lucide-react"; |
|
|
import type { LucideIcon } from "lucide-react"; |
|
|
import { ReactNode, useMemo } from "react"; |
|
|
import { ReactNode, useMemo } from "react"; |
|
|
import { Message, MessageState } from "../../../core/stores/messageStore/index.ts"; |
|
|
import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts"; |
|
|
import { Protobuf } from "@meshtastic/js"; |
|
|
import { Protobuf, Types } from "@meshtastic/js"; |
|
|
import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; |
|
|
import { Message } from "@core/stores/messageStore/types.ts"; |
|
|
|
|
|
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
|
|
|
|
|
|
|
|
|
interface MessageProps { |
|
|
interface MessageStatusInfo { |
|
|
message: Message; |
|
|
|
|
|
// locale?: string; // locale
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
interface MessageStatus { |
|
|
|
|
|
state: MessageState; |
|
|
|
|
|
displayText: string; |
|
|
displayText: string; |
|
|
icon: LucideIcon; |
|
|
icon: LucideIcon; |
|
|
ariaLabel: string; |
|
|
ariaLabel: string; |
|
|
iconClassName?: string; |
|
|
iconClassName?: string; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = { |
|
|
const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = { |
|
|
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500 dark:text-green-500" }, |
|
|
[MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" }, |
|
|
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400 dark:text-slate-400" }, |
|
|
[MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" }, |
|
|
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" }, |
|
|
[MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" }, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
const getMessageStatus = (state: MessageState): MessageStatus => |
|
|
const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" }; |
|
|
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown" }; |
|
|
|
|
|
|
|
|
const getMessageStatusInfo = (state: MessageState): MessageStatusInfo => |
|
|
|
|
|
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS; |
|
|
|
|
|
|
|
|
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => ( |
|
|
const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => ( |
|
|
<TooltipProvider delayDuration={300}> |
|
|
<TooltipProvider delayDuration={300}> |
|
|
<Tooltip> |
|
|
<Tooltip> |
|
|
<TooltipTrigger asChild>{children}</TooltipTrigger> |
|
|
<TooltipTrigger asChild>{children}</TooltipTrigger> |
|
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> |
|
|
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> |
|
|
{status.displayText} |
|
|
{statusInfo.displayText} |
|
|
<TooltipArrow className="fill-slate-800" /> |
|
|
<TooltipArrow className="fill-slate-800" /> |
|
|
</TooltipContent> |
|
|
</TooltipContent> |
|
|
</Tooltip> |
|
|
</Tooltip> |
|
|
</TooltipProvider> |
|
|
</TooltipProvider> |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
const StatusIcon = ({ status }: { status: MessageStatus }) => { |
|
|
interface MessageItemProps { |
|
|
const Icon = status.icon; |
|
|
message: Message; |
|
|
const iconClass = cn("size-4 shrink-0", status.iconClassName); |
|
|
} |
|
|
return ( |
|
|
|
|
|
<StatusTooltip status={status}> |
|
|
|
|
|
<span aria-label={status.ariaLabel} role="img"> |
|
|
|
|
|
<Icon className={iconClass} aria-hidden="true" /> |
|
|
|
|
|
</span> |
|
|
|
|
|
</StatusTooltip> |
|
|
|
|
|
); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => { |
|
|
export const MessageItem = ({ message }: MessageItemProps) => { |
|
|
const _date = useMemo(() => new Date(date), [date]); |
|
|
const { getNode } = useDevice(); |
|
|
const locale = 'en-US'; // TODO: Make dynamic
|
|
|
const { getMyNodeNum } = useMessageStore() |
|
|
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 ( |
|
|
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => { |
|
|
<time dateTime={_date.toISOString()} className={cn("text-xs", className)}> |
|
|
return message.from != null ? getNode(message.from) : null; |
|
|
<span aria-hidden="true">{formattedTime}</span> |
|
|
}, [getNode, message.from]); |
|
|
<span className="sr-only">{fullDate}</span> |
|
|
|
|
|
</time> |
|
|
|
|
|
); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export const MessageItem = ({ message }: MessageProps) => { |
|
|
|
|
|
const { getDevices } = useDeviceStore(); |
|
|
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]); |
|
|
const { getNode } = useDevice() |
|
|
const { displayName, shortName } = useMemo(() => { |
|
|
|
|
|
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0'); |
|
|
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => { |
|
|
const last4 = userIdHex.slice(-4); |
|
|
if (message?.from === null || message?.from === undefined) return null; |
|
|
const fallbackName = `Meshtastic ${last4}` |
|
|
const fromNode = getNode(message.from); |
|
|
|
|
|
if (fromNode) { |
|
|
|
|
|
return fromNode ?? null; |
|
|
|
|
|
} |
|
|
|
|
|
return null; |
|
|
|
|
|
}, [getDevices, message.from]); |
|
|
|
|
|
|
|
|
|
|
|
const { shortName, displayName, } = useMemo(() => { |
|
|
|
|
|
const fallbackName = message.from |
|
|
|
|
|
const longName = messageUser?.user?.longName; |
|
|
const longName = messageUser?.user?.longName; |
|
|
const shortName = messageUser?.user?.shortName ?? fallbackName; |
|
|
const derivedShortName = messageUser?.user?.shortName || fallbackName; |
|
|
const displayName = longName || fallbackName; |
|
|
const derivedDisplayName = longName || derivedShortName; |
|
|
return { shortName, displayName, from: message.from }; |
|
|
return { displayName: derivedDisplayName, shortName: derivedShortName }; |
|
|
}, [messageUser, message.from]); |
|
|
}, [messageUser, message.from]); |
|
|
|
|
|
|
|
|
const messageStatus = getMessageStatus(message.state); |
|
|
const messageStatusInfo = getMessageStatusInfo(message.state); |
|
|
const messageText = message?.message ?? ""; |
|
|
const StatusIconComponent = messageStatusInfo.icon; |
|
|
const messageDate = message?.date; |
|
|
|
|
|
|
|
|
const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]); |
|
|
|
|
|
const locale = 'en-US'; // TODO: Make dynamic via props or context
|
|
|
|
|
|
|
|
|
|
|
|
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( |
|
|
const messageItemWrapperClass = cn( |
|
|
"group w-full py-2 relative list-none", |
|
|
"group w-full py-2 relative list-none", |
|
|
@ -106,39 +95,48 @@ export const MessageItem = ({ message }: MessageProps) => { |
|
|
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20", |
|
|
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20", |
|
|
"transition-colors duration-100 ease-in-out", |
|
|
"transition-colors duration-100 ease-in-out", |
|
|
); |
|
|
); |
|
|
|
|
|
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400"; |
|
|
|
|
|
|
|
|
const gridGapClass = "gap-x-2"; |
|
|
|
|
|
|
|
|
|
|
|
const baseTextStyle = "text-sm text-slate-800 dark:text-slate-200"; |
|
|
|
|
|
const nameTextStyle = "font-medium text-slate-900 dark:text-slate-100 mr-2"; |
|
|
|
|
|
const dateTextStyle = "text-slate-500 dark:text-slate-400"; |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
return ( |
|
|
<li className={messageItemWrapperClass}> |
|
|
<li className={messageItemWrapperClass}> |
|
|
<div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}> |
|
|
<div className="grid grid-cols-[auto_1fr] gap-x-2"> |
|
|
<Avatar size="sm" text={shortName} className="pt-0.5" /> |
|
|
<Avatar size="sm" text={shortName} className="pt-0.5" /> |
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-1.5 min-w-0"> |
|
|
<div className="flex flex-col gap-0.5 min-w-0"> |
|
|
{messageDate != null ? ( |
|
|
|
|
|
<div className="flex items-center gap-1.5"> |
|
|
<div className="flex items-center gap-1.5"> |
|
|
<span className={nameTextStyle} aria-hidden="true"> |
|
|
<span className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate mr-1"> |
|
|
{displayName} |
|
|
{displayName} |
|
|
</span> |
|
|
</span> |
|
|
<TimeDisplay date={messageDate} className={dateTextStyle} /> |
|
|
{messageDate && ( |
|
|
<StatusIcon status={messageStatus} /> |
|
|
<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> |
|
|
</div> |
|
|
) : null} |
|
|
|
|
|
|
|
|
|
|
|
<div className={cn(baseTextStyle, "whitespace-pre-wrap")}> |
|
|
{message?.message && ( |
|
|
{messageText} |
|
|
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words"> |
|
|
|
|
|
{message.message} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{/* TODO: Restore when reactions/emojis are functional */} |
|
|
{/* Actions Menu Placeholder */} |
|
|
{/* <MessageActionsMenu |
|
|
{/* <div className="absolute top-1 right-1"> |
|
|
onReply={() => console.log("Reply to message:", message.messageId)} |
|
|
<MessageActionsMenu onReply={() => console.log("Reply")} /> |
|
|
/> */} |
|
|
</div> */} |
|
|
</li> |
|
|
</li> |
|
|
); |
|
|
); |
|
|
}; |
|
|
}; |