|
|
|
@ -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, 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 }, |
|
|
|
[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 }) => ( |
|
|
|
<TooltipProvider> |
|
|
|
<TooltipProvider delayDuration={300}> |
|
|
|
<Tooltip> |
|
|
|
<TooltipTrigger asChild>{children}</TooltipTrigger> |
|
|
|
<TooltipContent /* ...props... */ > |
|
|
|
<TooltipContent className="bg-gray-800 text-white px-2 py-1 rounded text-xs"> |
|
|
|
{status.displayText} |
|
|
|
<TooltipArrow className="fill-slate-800" /> |
|
|
|
<TooltipArrow className="fill-gray-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 StatusIcon = ({ status, className }: { status: MessageStatus; className?: string }) => { |
|
|
|
const Icon = status.icon; |
|
|
|
const iconClass = cn("w-3.5 h-3.5 shrink-0", className); |
|
|
|
return ( |
|
|
|
<StatusTooltip status={status}> |
|
|
|
<Icon className={iconClass} {...otherProps} color={isFailed ? "currentColor" : undefined} /> |
|
|
|
<span aria-label={status.ariaLabel} role="img"> |
|
|
|
<Icon className={iconClass} aria-hidden="true" /> |
|
|
|
</span> |
|
|
|
</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
|
|
|
|
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 ( |
|
|
|
<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> |
|
|
|
<time dateTime={_date.toISOString()} className={cn("text-xs", className)}> |
|
|
|
<span aria-hidden="true">{formattedTime}</span> |
|
|
|
<span className="sr-only">{fullDate}</span> |
|
|
|
</time> |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
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 ( |
|
|
|
<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} /> |
|
|
|
<li className={messageItemWrapperClass}> |
|
|
|
<div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}> |
|
|
|
<Avatar size="sm" text={shortName} className={cn(avatarSizeClass, "pt-0.5")} /> |
|
|
|
|
|
|
|
<div className="flex flex-col gap-1.5 min-w-0"> |
|
|
|
{messageDate != null ? ( |
|
|
|
<div className="flex items-center gap-1.5"> |
|
|
|
<span className={nameTextStyle} aria-hidden="true"> |
|
|
|
{displayName} |
|
|
|
</span> |
|
|
|
<TimeDisplay date={messageDate} className={dateTextStyle} /> |
|
|
|
<StatusIcon |
|
|
|
status={messageStatus} |
|
|
|
className={cn(isFailed ? statusIconFailedColor : statusIconBaseColor)} |
|
|
|
/> |
|
|
|
</div> |
|
|
|
) : null} |
|
|
|
|
|
|
|
<div className={cn(baseTextStyle, "whitespace-pre-wrap")}> |
|
|
|
{messageText} |
|
|
|
</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> |
|
|
|
<MessageActionsMenu |
|
|
|
onReply={() => console.log("Reply to message:", message.messageId)} |
|
|
|
/> |
|
|
|
</li> |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
}; |