feat: added message persistance
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
||||
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||
import type { Message as Message } from "@core/stores/messageStore.ts";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export interface ChannelChatProps {
|
||||
messages?: MessageWithState[];
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
const EmptyState = () => (
|
||||
@@ -17,8 +17,6 @@ const EmptyState = () => (
|
||||
export const ChannelChat = ({
|
||||
messages = [],
|
||||
}: ChannelChatProps) => {
|
||||
const { nodes } = useDevice();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -59,12 +57,12 @@ export const ChannelChat = ({
|
||||
>
|
||||
<div className="flex flex-col justify-end min-h-full">
|
||||
{messages?.map((message, index) => (
|
||||
<Message
|
||||
key={message.id}
|
||||
<MessageItem
|
||||
key={message.messageId + index}
|
||||
message={message}
|
||||
sender={nodes.get(message.from)}
|
||||
lastMsgSameUser={
|
||||
index > 0 && messages[index - 1].from === message.from
|
||||
index > 0 &&
|
||||
messages[index - 1].from === message.from
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import {
|
||||
type MessageWithState,
|
||||
useDeviceStore,
|
||||
} from "@core/stores/deviceStore.ts";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
|
||||
|
||||
type MessageStateValue = {
|
||||
state: string;
|
||||
icon: LucideIcon;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
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 MESSAGE_STATES: Record<string, MessageStateValue> = {
|
||||
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
|
||||
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
|
||||
FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" },
|
||||
};
|
||||
|
||||
const getMessageState = (state: MessageState): MessageStateValue => {
|
||||
switch (state) {
|
||||
case MESSAGE_STATES.ACK.state:
|
||||
return MESSAGE_STATES.ACK;
|
||||
case MESSAGE_STATES.WAITING.state:
|
||||
return MESSAGE_STATES.WAITING;
|
||||
case MESSAGE_STATES.FAILED.state:
|
||||
return MESSAGE_STATES.FAILED;
|
||||
default:
|
||||
return MESSAGE_STATES.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
{getMessageState(state).displayText ?? "An unknown error occurred"};
|
||||
<TooltipArrow className="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
const msgState = getMessageState(state);
|
||||
|
||||
const isFailed = msgState.state === 'failed'
|
||||
|
||||
const iconClass = cn(
|
||||
className,
|
||||
"text-slate-500 dark:text-slate-400 size-5 shrink-0"
|
||||
);
|
||||
|
||||
const Icon = msgState.icon;
|
||||
|
||||
return (
|
||||
<StatusTooltip state={state}>
|
||||
<Icon
|
||||
className={iconClass}
|
||||
{...otherProps}
|
||||
color={isFailed ? "red" : "currentColor"}
|
||||
/>
|
||||
</StatusTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => (
|
||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date?.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date?.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
export const Message = (({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
console.log('Message', message);
|
||||
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
const isDeviceUser = useMemo(
|
||||
() =>
|
||||
getDevices()
|
||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
||||
.includes(message.from),
|
||||
[getDevices, message.from]
|
||||
);
|
||||
|
||||
const messageUser = sender?.user;
|
||||
|
||||
const getMessageTextStyles = (state: MessageState) => {
|
||||
const msgState = getMessageState(state);
|
||||
const isAcknowledged = msgState.state === 'ack'
|
||||
const isFailed = msgState.state === 'failed'
|
||||
|
||||
return cn(
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged
|
||||
? "text-slate-900 dark:text-white"
|
||||
: "text-slate-900 dark:text-slate-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
);
|
||||
};
|
||||
|
||||
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full px-4 justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-wrap items-start py-1",
|
||||
isDeviceUser && "items-end"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!lastMsgSameUser && (
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName ?? "UNK"} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimeDisplay date={message?.rxTime} />
|
||||
<div className="flex place-items-center gap-2 pb-2">
|
||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>
|
||||
{message.data}
|
||||
</div>
|
||||
<StatusIcon state={message.state} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import type { Types } from "@meshtastic/core";
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||
import { ChatTypes, useMessageStore } from "@core/stores/messageStore.ts";
|
||||
|
||||
export interface MessageInputProps {
|
||||
to: Types.Destination;
|
||||
@@ -18,55 +18,40 @@ export const MessageInput = ({
|
||||
channel,
|
||||
maxBytes,
|
||||
}: MessageInputProps) => {
|
||||
const {
|
||||
connection,
|
||||
messageDraft,
|
||||
setMessageDraft,
|
||||
hardware,
|
||||
} = useDevice();
|
||||
const { setMessageState } = useMessageStore()
|
||||
const myNodeNum = hardware.myNodeNum;
|
||||
const { connection, messageDraft, setMessageDraft } = useDevice();
|
||||
const { setMessageState, activeChat } = useMessageStore();
|
||||
|
||||
const [localDraft, setLocalDraft] = useState(messageDraft);
|
||||
const [messageBytes, setMessageBytes] = useState(0);
|
||||
|
||||
const debouncedSetMessageDraft = useMemo(
|
||||
() => debounce(setMessageDraft, 300),
|
||||
[setMessageDraft],
|
||||
() => debounce((value: string) => setMessageDraft(value), 300),
|
||||
[setMessageDraft]
|
||||
);
|
||||
|
||||
// sends the message to the selected destination
|
||||
const sendText = useCallback(
|
||||
async (message: string) => {
|
||||
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||
|
||||
await connection
|
||||
?.sendText(message, to, true, channel)
|
||||
.then((id: number) =>
|
||||
setMessageState(
|
||||
to === "broadcast" ? "broadcast" : "direct",
|
||||
channel,
|
||||
to as number,
|
||||
myNodeNum,
|
||||
id,
|
||||
"ack",
|
||||
)
|
||||
)
|
||||
.catch((e: Types.PacketError) =>
|
||||
setMessageState(
|
||||
to === "broadcast" ? "broadcast" : "direct",
|
||||
channel,
|
||||
to as number,
|
||||
myNodeNum,
|
||||
e.id,
|
||||
e.error,
|
||||
)
|
||||
);
|
||||
},
|
||||
[channel, connection, myNodeNum, setMessageState, to],
|
||||
);
|
||||
const chatType = to === 'broadcast' ? ChatTypes.BROADCAST : ChatTypes.DIRECT;
|
||||
|
||||
const sendText = useCallback(async (message: string) => {
|
||||
try {
|
||||
const messageId = await connection?.sendText(message, to, true, channel);
|
||||
if (messageId !== undefined) {
|
||||
setMessageState({ type: chatType, key: activeChat, messageId, newState: 'ack' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
setMessageState({
|
||||
type: chatType,
|
||||
key: activeChat,
|
||||
messageId: e?.id,
|
||||
newState: 'failed',
|
||||
});
|
||||
}
|
||||
}, [channel, connection, setMessageState, to, activeChat, chatType]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const byteLength = new Blob([newValue]).size;
|
||||
const byteLength = calculateBytes(newValue);
|
||||
|
||||
if (byteLength <= maxBytes) {
|
||||
setLocalDraft(newValue);
|
||||
@@ -75,24 +60,29 @@ export const MessageInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const nextValue = localDraft + (e.nativeEvent as InputEvent).data;
|
||||
if (calculateBytes(nextValue) > maxBytes) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!localDraft.trim()) return;
|
||||
|
||||
startTransition(() => {
|
||||
sendText(localDraft.trim());
|
||||
setLocalDraft("");
|
||||
setMessageDraft("");
|
||||
setMessageBytes(0);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
className="w-full"
|
||||
action={(formData: FormData) => {
|
||||
// prevent user from sending blank/empty message
|
||||
if (localDraft === "") return;
|
||||
const message = formData.get("messageInput") as string;
|
||||
startTransition(() => {
|
||||
sendText(message);
|
||||
setLocalDraft("");
|
||||
setMessageDraft("");
|
||||
setMessageBytes(0);
|
||||
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex grow gap-2 ">
|
||||
<form className="w-full" onSubmit={handleSubmit}>
|
||||
<div className="flex grow gap-2">
|
||||
<label className="w-full">
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -101,13 +91,18 @@ export const MessageInput = ({
|
||||
placeholder="Enter Message"
|
||||
value={localDraft}
|
||||
onChange={handleInputChange}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
|
||||
{messageBytes}/{maxBytes}
|
||||
</label>
|
||||
|
||||
<Button type="submit" className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white">
|
||||
<Button
|
||||
type="submit"
|
||||
className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white"
|
||||
>
|
||||
<SendIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/UI/Tooltip.tsx";
|
||||
import { useDeviceStore } from "@core/stores/deviceStore.ts";
|
||||
import { MessageState, 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 } from "@core/services/types.ts";
|
||||
import { Message } from "@core/stores/messageStore.ts";
|
||||
|
||||
interface MessageProps {
|
||||
lastMsgSameUser: boolean;
|
||||
@@ -73,14 +73,15 @@ const getMessageTextStyles = (status: MessageStatus) => {
|
||||
);
|
||||
};
|
||||
|
||||
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
|
||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
|
||||
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => {
|
||||
const _date = new Date(date);
|
||||
return (<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{_date?.toLocaleDateString()}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
|
||||
{_date?.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
</div>)
|
||||
};
|
||||
|
||||
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
||||
const { getDevices } = useDeviceStore();
|
||||
|
||||
Reference in New Issue
Block a user