feat: added message persistance
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"homepage": "https://meshtastic.org",
|
"homepage": "https://meshtastic.org",
|
||||||
"ddbependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.2.3",
|
"@bufbuild/protobuf": "^2.2.3",
|
||||||
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.0-0",
|
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.0-0",
|
||||||
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
|
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||||
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
import type { Message as Message } from "@core/stores/messageStore.ts";
|
||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
export interface ChannelChatProps {
|
export interface ChannelChatProps {
|
||||||
messages?: MessageWithState[];
|
messages?: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
@@ -17,8 +17,6 @@ const EmptyState = () => (
|
|||||||
export const ChannelChat = ({
|
export const ChannelChat = ({
|
||||||
messages = [],
|
messages = [],
|
||||||
}: ChannelChatProps) => {
|
}: ChannelChatProps) => {
|
||||||
const { nodes } = useDevice();
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = 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">
|
<div className="flex flex-col justify-end min-h-full">
|
||||||
{messages?.map((message, index) => (
|
{messages?.map((message, index) => (
|
||||||
<Message
|
<MessageItem
|
||||||
key={message.id}
|
key={message.messageId + index}
|
||||||
message={message}
|
message={message}
|
||||||
sender={nodes.get(message.from)}
|
|
||||||
lastMsgSameUser={
|
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 type { Types } from "@meshtastic/core";
|
||||||
import { SendIcon } from "lucide-react";
|
import { SendIcon } from "lucide-react";
|
||||||
import { startTransition, useCallback, useMemo, useState } from "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 {
|
export interface MessageInputProps {
|
||||||
to: Types.Destination;
|
to: Types.Destination;
|
||||||
@@ -18,55 +18,40 @@ export const MessageInput = ({
|
|||||||
channel,
|
channel,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
}: MessageInputProps) => {
|
}: MessageInputProps) => {
|
||||||
const {
|
const { connection, messageDraft, setMessageDraft } = useDevice();
|
||||||
connection,
|
const { setMessageState, activeChat } = useMessageStore();
|
||||||
messageDraft,
|
|
||||||
setMessageDraft,
|
|
||||||
hardware,
|
|
||||||
} = useDevice();
|
|
||||||
const { setMessageState } = useMessageStore()
|
|
||||||
const myNodeNum = hardware.myNodeNum;
|
|
||||||
const [localDraft, setLocalDraft] = useState(messageDraft);
|
const [localDraft, setLocalDraft] = useState(messageDraft);
|
||||||
const [messageBytes, setMessageBytes] = useState(0);
|
const [messageBytes, setMessageBytes] = useState(0);
|
||||||
|
|
||||||
const debouncedSetMessageDraft = useMemo(
|
const debouncedSetMessageDraft = useMemo(
|
||||||
() => debounce(setMessageDraft, 300),
|
() => debounce((value: string) => setMessageDraft(value), 300),
|
||||||
[setMessageDraft],
|
[setMessageDraft]
|
||||||
);
|
);
|
||||||
|
|
||||||
// sends the message to the selected destination
|
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||||
const sendText = useCallback(
|
|
||||||
async (message: string) => {
|
|
||||||
|
|
||||||
await connection
|
const chatType = to === 'broadcast' ? ChatTypes.BROADCAST : ChatTypes.DIRECT;
|
||||||
?.sendText(message, to, true, channel)
|
|
||||||
.then((id: number) =>
|
const sendText = useCallback(async (message: string) => {
|
||||||
setMessageState(
|
try {
|
||||||
to === "broadcast" ? "broadcast" : "direct",
|
const messageId = await connection?.sendText(message, to, true, channel);
|
||||||
channel,
|
if (messageId !== undefined) {
|
||||||
to as number,
|
setMessageState({ type: chatType, key: activeChat, messageId, newState: 'ack' });
|
||||||
myNodeNum,
|
}
|
||||||
id,
|
} catch (e: any) {
|
||||||
"ack",
|
setMessageState({
|
||||||
)
|
type: chatType,
|
||||||
)
|
key: activeChat,
|
||||||
.catch((e: Types.PacketError) =>
|
messageId: e?.id,
|
||||||
setMessageState(
|
newState: 'failed',
|
||||||
to === "broadcast" ? "broadcast" : "direct",
|
});
|
||||||
channel,
|
}
|
||||||
to as number,
|
}, [channel, connection, setMessageState, to, activeChat, chatType]);
|
||||||
myNodeNum,
|
|
||||||
e.id,
|
|
||||||
e.error,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[channel, connection, myNodeNum, setMessageState, to],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
const byteLength = new Blob([newValue]).size;
|
const byteLength = calculateBytes(newValue);
|
||||||
|
|
||||||
if (byteLength <= maxBytes) {
|
if (byteLength <= maxBytes) {
|
||||||
setLocalDraft(newValue);
|
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 (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<form
|
<form className="w-full" onSubmit={handleSubmit}>
|
||||||
className="w-full"
|
<div className="flex grow gap-2">
|
||||||
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 ">
|
|
||||||
<label className="w-full">
|
<label className="w-full">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -101,13 +91,18 @@ export const MessageInput = ({
|
|||||||
placeholder="Enter Message"
|
placeholder="Enter Message"
|
||||||
value={localDraft}
|
value={localDraft}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
onBeforeInput={handleBeforeInput}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
|
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
|
||||||
{messageBytes}/{maxBytes}
|
{messageBytes}/{maxBytes}
|
||||||
</label>
|
</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} />
|
<SendIcon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@components/UI/Tooltip.tsx";
|
} 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 { 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/services/types.ts";
|
import { Message } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
lastMsgSameUser: boolean;
|
lastMsgSameUser: boolean;
|
||||||
@@ -73,14 +73,15 @@ const getMessageTextStyles = (status: MessageStatus) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
|
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => {
|
||||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
const _date = new Date(date);
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
|
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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>)
|
||||||
);
|
};
|
||||||
|
|
||||||
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
||||||
const { getDevices } = useDeviceStore();
|
const { getDevices } = useDeviceStore();
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Types } from "@meshtastic/js";
|
||||||
|
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
|
class PacketToMessageDTO {
|
||||||
|
channel: Types.ChannelNumber;
|
||||||
|
to: number;
|
||||||
|
from: number;
|
||||||
|
date: string;
|
||||||
|
messageId: number;
|
||||||
|
state: MessageState;
|
||||||
|
message: string;
|
||||||
|
type: MessageType;
|
||||||
|
|
||||||
|
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
|
||||||
|
const payload = data
|
||||||
|
|
||||||
|
this.channel = payload.channel
|
||||||
|
this.to = payload.to;
|
||||||
|
this.from = payload.from;
|
||||||
|
this.date = new Date(payload.rxTime).toISOString();
|
||||||
|
this.messageId = payload.id;
|
||||||
|
this.state = payload.from !== nodeNum ? "ack" : "waiting";
|
||||||
|
this.message = payload.data;
|
||||||
|
this.type = payload.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
toMessage(): Message {
|
||||||
|
return {
|
||||||
|
channel: this.channel,
|
||||||
|
to: this.to,
|
||||||
|
from: this.from,
|
||||||
|
date: this.date,
|
||||||
|
messageId: this.messageId,
|
||||||
|
state: this.state,
|
||||||
|
message: this.message,
|
||||||
|
type: this.type,
|
||||||
|
} as Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PacketToMessageDTO;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { StateStorage } from "zustand/middleware";
|
import { StateStorage } from "zustand/middleware";
|
||||||
import { get, set, del } from "idb-keyval";
|
import { get, set, del } from "idb-keyval";
|
||||||
|
|
||||||
export const zustandIDBStorage: StateStorage = {
|
export const zustandIndexDBStorage: StateStorage = {
|
||||||
getItem: async (name: string): Promise<string | null> => {
|
getItem: async (name: string): Promise<string | null> => {
|
||||||
return (await get(name)) || null;
|
return (await get(name)) || null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface MessageWithState extends Types.PacketMetadata<string> {
|
|||||||
state: MessageState;
|
state: MessageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error;
|
export type MessageState = "ack" | "waiting" | 'failed';
|
||||||
|
|
||||||
export interface ProcessPacketParams {
|
export interface ProcessPacketParams {
|
||||||
from: number;
|
from: number;
|
||||||
|
|||||||
@@ -2,40 +2,60 @@ import { create } from 'zustand';
|
|||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { Types } from '@meshtastic/core';
|
import { Types } from '@meshtastic/core';
|
||||||
import { zustandIDBStorage } from "@core/services/messaging/db.ts";
|
import { zustandIndexDBStorage } from "@core/services/messaging/db.ts";
|
||||||
|
|
||||||
export interface MessageWithState {
|
const MESSAGE_STATES = {
|
||||||
id: number;
|
ack: "ack",
|
||||||
from: number;
|
waiting: "waiting",
|
||||||
|
failed: 'failed',
|
||||||
|
};
|
||||||
|
export type MessageState = keyof typeof MESSAGE_STATES;
|
||||||
|
|
||||||
|
export const ChatTypes = {
|
||||||
|
DIRECT: "direct",
|
||||||
|
BROADCAST: "broadcast",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MessageType = "broadcast" | "direct";
|
||||||
|
|
||||||
|
interface MessageBase {
|
||||||
|
channel: Types.ChannelNumber;
|
||||||
to: number;
|
to: number;
|
||||||
channel: number;
|
from: number;
|
||||||
content: string;
|
date: string;
|
||||||
state: 'ack' | 'waiting' | 'failed';
|
messageId: number;
|
||||||
type: 'direct' | 'broadcast';
|
state: MessageState;
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageType = 'direct' | 'broadcast';
|
interface GenericMessage<T extends MessageType> extends MessageBase {
|
||||||
|
type: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Message = GenericMessage<'direct'> | GenericMessage<'broadcast'>;
|
||||||
|
|
||||||
export interface MessageStore {
|
export interface MessageStore {
|
||||||
messages: {
|
messages: {
|
||||||
direct: Record<number, MessageWithState[]>;
|
direct: Record<number, Record<number, Message>>; // node -> messageId -> Message
|
||||||
broadcast: Record<number, MessageWithState[]>;
|
broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
|
||||||
};
|
};
|
||||||
|
nodeNum: number;
|
||||||
activeChat: number;
|
activeChat: number;
|
||||||
chatType: MessageType;
|
chatType: MessageType;
|
||||||
|
|
||||||
|
setNodeNum: (nodeNum: number) => void;
|
||||||
|
getNodeNum: () => number;
|
||||||
setActiveChat: (chat: number) => void;
|
setActiveChat: (chat: number) => void;
|
||||||
setChatType: (type: MessageType) => void;
|
setChatType: (type: MessageType) => void;
|
||||||
addMessage: (message: MessageWithState) => void;
|
saveMessage: (message: Message) => void;
|
||||||
getMessages: (type: MessageType, key: number) => MessageWithState[];
|
setMessageState: (params: {
|
||||||
setMessageState: (
|
type: MessageType;
|
||||||
type: MessageType,
|
key: number;
|
||||||
key: number,
|
messageId: number;
|
||||||
messageId: number,
|
newState?: MessageState;
|
||||||
newState: MessageWithState['state']
|
}) => void;
|
||||||
) => void;
|
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
|
getMessages: (type: MessageType, options: { myNodeNum?: number; otherNodeNum?: number; channel?: number }) => Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMessageStore = create<MessageStore>()(
|
export const useMessageStore = create<MessageStore>()(
|
||||||
@@ -45,9 +65,16 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
direct: {},
|
direct: {},
|
||||||
broadcast: {},
|
broadcast: {},
|
||||||
},
|
},
|
||||||
|
activeChat: 0,
|
||||||
activeChat: Types.ChannelNumber.Primary,
|
|
||||||
chatType: 'broadcast',
|
chatType: 'broadcast',
|
||||||
|
nodeNum: 0,
|
||||||
|
setNodeNum: (nodeNum) => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
state.nodeNum = nodeNum;
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
getNodeNum: () => get().nodeNum,
|
||||||
|
|
||||||
setActiveChat: (chat) => {
|
setActiveChat: (chat) => {
|
||||||
set(produce((state: MessageStore) => {
|
set(produce((state: MessageStore) => {
|
||||||
@@ -61,48 +88,62 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage: (message) => {
|
saveMessage: (message) => {
|
||||||
set(produce((state: MessageStore) => {
|
set(produce((state: MessageStore) => {
|
||||||
const group = message.type === 'direct' ? state.messages.direct : state.messages.broadcast;
|
const group = state.messages[message.type];
|
||||||
const key = message.type === 'direct' ? message.from : message.channel;
|
const key = message.type === 'direct' ? Number(message.from) : Number(message.channel);
|
||||||
|
|
||||||
if (!group[key]) {
|
if (!group[key]) {
|
||||||
group[key] = [];
|
group[key] = {};
|
||||||
}
|
}
|
||||||
group[key].push(message);
|
group[key][message.messageId] = message;
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
setMessageState: ({ type, key, messageId, newState = 'ack' }) => {
|
||||||
getMessages: (type, key) => {
|
|
||||||
const group = type === 'direct' ? get().messages.direct : get().messages.broadcast;
|
|
||||||
return group[key] ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
setMessageState: (type, key, messageId, newState) => {
|
|
||||||
set(produce((state: MessageStore) => {
|
set(produce((state: MessageStore) => {
|
||||||
const group = type === 'direct' ? state.messages.direct : state.messages.broadcast;
|
const group = state.messages[type];
|
||||||
const messages = group[key];
|
const messageMap = group[key];
|
||||||
if (!messages) return;
|
if (!messageMap || !messageMap[messageId]) return;
|
||||||
const message = messages.find((msg) => msg.id === messageId);
|
messageMap[messageId].state = newState;
|
||||||
if (message) {
|
|
||||||
message.state = newState;
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
clearMessages: () => {
|
clearMessages: () => {
|
||||||
set(produce((state: MessageStore) => {
|
set(produce((state: MessageStore) => {
|
||||||
state.messages.direct = {};
|
state.messages.direct = {};
|
||||||
state.messages.broadcast = {};
|
state.messages.broadcast = {};
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
getMessages: (type, options) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
if (type === 'broadcast' && options.channel !== undefined) {
|
||||||
|
const messageMap = state.messages.broadcast[options.channel] ?? {};
|
||||||
|
return Object.values(messageMap).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'direct' && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
|
||||||
|
const receivedMap = state.messages.direct[options.otherNodeNum] ?? {};
|
||||||
|
const sentMap = state.messages.direct[options.myNodeNum] ?? {};
|
||||||
|
|
||||||
|
// Pull messages where I am the sender and otherNode is the receiver
|
||||||
|
const sentMessages = Object.values(sentMap).filter(msg => msg.to === options.otherNodeNum);
|
||||||
|
|
||||||
|
// Pull messages received from otherNode
|
||||||
|
const receivedMessages = Object.values(receivedMap);
|
||||||
|
|
||||||
|
// Merge and sort chronologically
|
||||||
|
return [...receivedMessages, ...sentMessages].sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'mesh-messages',
|
name: 'meshtastic-message-store',
|
||||||
storage: createJSONStorage(() => zustandIDBStorage),
|
storage: createJSONStorage(() => zustandIndexDBStorage),
|
||||||
// ✅ No need for partialize magic — simple object storage
|
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
activeChat: state.activeChat,
|
|
||||||
chatType: state.chatType,
|
|
||||||
messages: state.messages,
|
messages: state.messages,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Device } from "@core/stores/deviceStore.ts";
|
import type { Device } from "@core/stores/deviceStore.ts";
|
||||||
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
||||||
import type { MessageStore } from "@core/stores/messageStore.ts";
|
import type { MessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||||
|
|
||||||
export const subscribeAll = (
|
export const subscribeAll = (
|
||||||
device: Device,
|
device: Device,
|
||||||
@@ -17,7 +18,7 @@ export const subscribeAll = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||||
console.log("routingPacket", routingPacket);
|
console.log("Routing Packet", routingPacket);
|
||||||
|
|
||||||
switch (routingPacket.data.variant.case) {
|
switch (routingPacket.data.variant.case) {
|
||||||
case "errorReason": {
|
case "errorReason": {
|
||||||
@@ -55,6 +56,7 @@ export const subscribeAll = (
|
|||||||
|
|
||||||
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||||
device.setHardware(nodeInfo);
|
device.setHardware(nodeInfo);
|
||||||
|
messageStore.setNodeNum(nodeInfo.myNodeNum);
|
||||||
myNodeNum = nodeInfo.myNodeNum;
|
myNodeNum = nodeInfo.myNodeNum;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,12 +87,13 @@ export const subscribeAll = (
|
|||||||
|
|
||||||
|
|
||||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||||
console.log("messagePacket", messagePacket);
|
console.log("before Message Packet", messagePacket);
|
||||||
messageStore.addMessage({
|
|
||||||
...messagePacket,
|
|
||||||
|
|
||||||
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
|
// incoming and outgoing messages are handled by this event listener
|
||||||
});
|
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
|
||||||
|
const message = dto.toMessage();
|
||||||
|
console.log("after Message Packet", message);
|
||||||
|
messageStore.saveMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
|
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
|
||||||
@@ -111,9 +114,6 @@ export const subscribeAll = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// connection.events.onQueueStatus.subscribe((queueStatus) => {
|
|
||||||
// device.setQueueStatus(queueStatus);
|
|
||||||
// });
|
|
||||||
|
|
||||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||||
if (routingPacket.data.variant.case === "errorReason") {
|
if (routingPacket.data.variant.case === "errorReason") {
|
||||||
|
|||||||
+28
-26
@@ -13,11 +13,11 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
import { useMessageStore } from "@core/stores/messageStore.ts";
|
import { ChatTypes, useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
export const MessagesPage = () => {
|
export const MessagesPage = () => {
|
||||||
const { channels, nodes, hardware, hasNodeError } = useDevice();
|
const { channels, nodes, hardware, hasNodeError } = useDevice();
|
||||||
const { getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
|
const { nodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
|
|
||||||
@@ -31,13 +31,15 @@ export const MessagesPage = () => {
|
|||||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||||
);
|
);
|
||||||
const currentChannel = channels.get(activeChat);
|
const currentChannel = channels.get(activeChat);
|
||||||
const node = nodes.get(activeChat);
|
|
||||||
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
|
|
||||||
|
|
||||||
const messageDestination = chatType === "direct" ? activeChat : "broadcast";
|
const otherNode = nodes.get(activeChat);
|
||||||
const messageChannel = chatType === "direct"
|
|
||||||
? Types.ChannelNumber.Primary
|
const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
|
||||||
: activeChat;
|
|
||||||
|
const isDirect = chatType === ChatTypes.DIRECT;
|
||||||
|
const isBroadcast = chatType === ChatTypes.BROADCAST;
|
||||||
|
|
||||||
|
const currentChat = { type: chatType, id: activeChat };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -51,7 +53,6 @@ export const MessagesPage = () => {
|
|||||||
: channel.index === 0
|
: channel.index === 0
|
||||||
? "Primary"
|
? "Primary"
|
||||||
: `Ch ${channel.index}`}
|
: `Ch ${channel.index}`}
|
||||||
active={activeChat === channel.index && chatType === "broadcast"}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatType("broadcast");
|
setChatType("broadcast");
|
||||||
setActiveChat(channel.index);
|
setActiveChat(channel.index);
|
||||||
@@ -71,21 +72,21 @@ export const MessagesPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{filteredNodes.map((node) => (
|
{filteredNodes.map((otherNode) => (
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
key={node.num}
|
key={otherNode.num}
|
||||||
label={node.user?.longName ??
|
label={otherNode.user?.longName ??
|
||||||
`!${numberToHexUnpadded(node.num)}`}
|
`!${numberToHexUnpadded(otherNode.num)}`}
|
||||||
active={activeChat === node.num && chatType === "direct"}
|
active={activeChat === otherNode.num && chatType === "direct"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatType("direct");
|
setChatType("direct");
|
||||||
setActiveChat(node.num);
|
setActiveChat(otherNode.num);
|
||||||
}}
|
}}
|
||||||
element={
|
element={
|
||||||
<Avatar
|
<Avatar
|
||||||
text={node.user?.shortName ?? node.num.toString()}
|
text={otherNode?.user?.shortName ?? otherNode.num.toString()}
|
||||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
className={cn(hasNodeError(otherNode?.num) && "text-red-500")}
|
||||||
showError={hasNodeError(node.num)}
|
showError={hasNodeError(otherNode?.num)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -126,23 +127,24 @@ export const MessagesPage = () => {
|
|||||||
: []}
|
: []}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{chatType === "broadcast" && currentChannel && (
|
{isBroadcast && currentChannel && (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ChannelChat
|
<ChannelChat
|
||||||
key={currentChannel.index}
|
messages={getMessages('broadcast', {
|
||||||
messages={getMessages('broadcast', currentChannel?.index)}
|
myNodeNum: nodeNum,
|
||||||
|
channel: currentChannel?.index
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chatType === "direct" && node && (
|
{isDirect && otherNode && (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ChannelChat
|
<ChannelChat
|
||||||
key={node.num}
|
messages={getMessages('direct', { myNodeNum: nodeNum, otherNodeNum: activeChat, })}
|
||||||
messages={getMessages('direct', node?.num)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,8 +153,8 @@ export const MessagesPage = () => {
|
|||||||
|
|
||||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
to={messageDestination}
|
to={currentChat.type === ChatTypes.DIRECT ? activeChat : ChatTypes.BROADCAST}
|
||||||
channel={messageChannel}
|
channel={currentChat.type === ChatTypes.DIRECT ? Types.ChannelNumber.Primary : currentChat.id}
|
||||||
maxBytes={200}
|
maxBytes={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user