Persistent message store (#814)
* Fix default filter behaviour * Persist message store * messageStore tests, node PKI validation Implement node validation and improve merging logic - Added `validateIncomingNode` function to validate new nodes against existing nodes, checking for public key conflicts and ensuring proper handling of node updates. - Updated `nodeDBFactory` to utilize the new validation function when adding nodes. - Enhanced `getNodes` method to optionally include the current node in the results. - Removed the `mergeNodeInfo` utility as its functionality is now integrated into the validation and merging process. - Updated tests to cover new validation logic and ensure correct behavior during node addition and merging. - Cleaned up unused utility functions related to key comparison. * refactor: reuse eviction logic for message and node stores * Update format, move hooks * Improve test performance * Update imports --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: Dan Ditomaso <dan.ditomaso@gmail.com>
This commit is contained in:
co-authored by
philon-
Dan Ditomaso
parent
799283fd46
commit
dcb44d27fe
+13
-7
@@ -1,10 +1,13 @@
|
|||||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||||
import { useMessageStore } from "@core/stores";
|
import { type MessageStore, useMessages } from "@core/stores";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("@core/stores", () => ({
|
vi.mock("@core/stores", () => ({
|
||||||
useMessageStore: vi.fn(() => ({
|
CurrentDeviceContext: {
|
||||||
|
_currentValue: { deviceId: 1234 },
|
||||||
|
},
|
||||||
|
useMessages: vi.fn(() => ({
|
||||||
deleteAllMessages: vi.fn(),
|
deleteAllMessages: vi.fn(),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
@@ -17,11 +20,14 @@ describe("DeleteMessagesDialog", () => {
|
|||||||
mockOnOpenChange.mockClear();
|
mockOnOpenChange.mockClear();
|
||||||
mockClearAllMessages.mockClear();
|
mockClearAllMessages.mockClear();
|
||||||
|
|
||||||
const mockedUseMessageStore = vi.mocked(useMessageStore);
|
const mockedUseMessages = vi.mocked(useMessages);
|
||||||
mockedUseMessageStore.mockImplementation(() => ({
|
mockedUseMessages.mockImplementation(
|
||||||
deleteAllMessages: mockClearAllMessages,
|
() =>
|
||||||
}));
|
({
|
||||||
mockedUseMessageStore.mockClear();
|
deleteAllMessages: mockClearAllMessages,
|
||||||
|
}) as unknown as MessageStore,
|
||||||
|
);
|
||||||
|
mockedUseMessages.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { useMessageStore } from "@core/stores";
|
import { useMessages } from "@core/stores";
|
||||||
import { AlertTriangleIcon } from "lucide-react";
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export const DeleteMessagesDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DeleteMessagesDialogProps) => {
|
}: DeleteMessagesDialogProps) => {
|
||||||
const { t } = useTranslation("dialog");
|
const { t } = useTranslation("dialog");
|
||||||
const { deleteAllMessages } = useMessageStore();
|
const { deleteAllMessages } = useMessages();
|
||||||
const handleCloseDialog = () => {
|
const handleCloseDialog = () => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { useMessageStore, useNodeDB } from "@core/stores";
|
import { useMessages, useNodeDB } from "@core/stores";
|
||||||
import { LockKeyholeOpenIcon } from "lucide-react";
|
import { LockKeyholeOpenIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
|
||||||
@@ -21,7 +21,7 @@ export const RefreshKeysDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: RefreshKeysDialogProps) => {
|
}: RefreshKeysDialogProps) => {
|
||||||
const { t } = useTranslation("dialog");
|
const { t } = useTranslation("dialog");
|
||||||
const { activeChat } = useMessageStore();
|
const { activeChat } = useMessages();
|
||||||
const { nodeErrors, getNode } = useNodeDB();
|
const { nodeErrors, getNode } = useNodeDB();
|
||||||
|
|
||||||
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useDevice, useMessageStore, useNodeDB } from "@core/stores";
|
import { useDevice, useMessages, useNodeDB } from "@core/stores";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
export function useRefreshKeysDialog() {
|
export function useRefreshKeysDialog() {
|
||||||
const { setDialogOpen } = useDevice();
|
const { setDialogOpen } = useDevice();
|
||||||
const { removeNode, clearNodeError, getNodeError } = useNodeDB();
|
const { removeNode, clearNodeError, getNodeError } = useNodeDB();
|
||||||
const { activeChat } = useMessageStore();
|
const { activeChat } = useMessages();
|
||||||
|
|
||||||
const handleCloseDialog = useCallback(() => {
|
const handleCloseDialog = useCallback(() => {
|
||||||
setDialogOpen("refreshKeys", false);
|
setDialogOpen("refreshKeys", false);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
|
|||||||
|
|
||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
const { addNodeDB } = useNodeDBStore();
|
const { addNodeDB } = useNodeDBStore();
|
||||||
const messageStore = useMessageStore();
|
const { addMessageStore } = useMessageStore();
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
|
|||||||
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
|
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
|
||||||
const device = addDevice(id);
|
const device = addDevice(id);
|
||||||
const nodeDB = addNodeDB(id);
|
const nodeDB = addNodeDB(id);
|
||||||
|
const messageStore = addMessageStore(id);
|
||||||
|
|
||||||
const connection = new MeshDevice(transport, id);
|
const connection = new MeshDevice(transport, id);
|
||||||
connection.configure();
|
connection.configure();
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ vi.mock("@core/stores", () => ({
|
|||||||
useDeviceStore: vi.fn(() => ({
|
useDeviceStore: vi.fn(() => ({
|
||||||
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
|
addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
|
||||||
})),
|
})),
|
||||||
useMessageStore: vi.fn(),
|
useMessageStore: vi.fn(() => ({
|
||||||
|
addMessageStore: vi.fn(),
|
||||||
|
})),
|
||||||
useNodeDBStore: vi.fn(() => ({
|
useNodeDBStore: vi.fn(() => ({
|
||||||
addNodeDB: vi.fn(),
|
addNodeDB: vi.fn(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
|||||||
|
|
||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
const { addNodeDB } = useNodeDBStore();
|
const { addNodeDB } = useNodeDBStore();
|
||||||
const messageStore = useMessageStore();
|
const { addMessageStore } = useMessageStore();
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
|
|
||||||
const { control, handleSubmit, register } = useForm<FormData>({
|
const { control, handleSubmit, register } = useForm<FormData>({
|
||||||
@@ -63,6 +63,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
|||||||
const transport = await TransportHTTP.create(data.ip, data.tls);
|
const transport = await TransportHTTP.create(data.ip, data.tls);
|
||||||
const device = addDevice(id);
|
const device = addDevice(id);
|
||||||
const nodeDB = addNodeDB(id);
|
const nodeDB = addNodeDB(id);
|
||||||
|
const messageStore = addMessageStore(id);
|
||||||
|
|
||||||
const connection = new MeshDevice(transport, id);
|
const connection = new MeshDevice(transport, id);
|
||||||
connection.configure();
|
connection.configure();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
|
|||||||
|
|
||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
const { addNodeDB } = useNodeDBStore();
|
const { addNodeDB } = useNodeDBStore();
|
||||||
const messageStore = useMessageStore();
|
const { addMessageStore } = useMessageStore();
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
|
|||||||
const id = randId();
|
const id = randId();
|
||||||
const device = addDevice(id);
|
const device = addDevice(id);
|
||||||
const nodeDB = addNodeDB(id);
|
const nodeDB = addNodeDB(id);
|
||||||
|
const messageStore = addMessageStore(id);
|
||||||
|
|
||||||
setSelectedDevice(id);
|
setSelectedDevice(id);
|
||||||
const transport = await TransportWebSerial.createFromPort(port);
|
const transport = await TransportWebSerial.createFromPort(port);
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ const mockSetDraft = vi.fn();
|
|||||||
const mockGetDraft = vi.fn();
|
const mockGetDraft = vi.fn();
|
||||||
const mockClearDraft = vi.fn();
|
const mockClearDraft = vi.fn();
|
||||||
|
|
||||||
vi.mock("@core/stores/messageStore", () => ({
|
vi.mock("@core/stores", () => ({
|
||||||
useMessageStore: vi.fn(() => ({
|
CurrentDeviceContext: {
|
||||||
|
_currentValue: { deviceId: 1234 },
|
||||||
|
},
|
||||||
|
useMessages: vi.fn(() => ({
|
||||||
setDraft: mockSetDraft,
|
setDraft: mockSetDraft,
|
||||||
getDraft: mockGetDraft,
|
getDraft: mockGetDraft,
|
||||||
clearDraft: mockClearDraft,
|
clearDraft: mockClearDraft,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import { useMessageStore } from "@core/stores";
|
import { useMessages } from "@core/stores";
|
||||||
import type { Types } from "@meshtastic/core";
|
import type { Types } from "@meshtastic/core";
|
||||||
import { SendIcon } from "lucide-react";
|
import { SendIcon } from "lucide-react";
|
||||||
import { startTransition, useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
@@ -13,7 +13,7 @@ export interface MessageInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
|
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
|
||||||
const { setDraft, getDraft, clearDraft } = useMessageStore();
|
const { setDraft, getDraft, clearDraft } = useMessages();
|
||||||
const { t } = useTranslation("messages");
|
const { t } = useTranslation("messages");
|
||||||
|
|
||||||
const calculateBytes = (text: string) => new Blob([text]).size;
|
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@components/UI/Tooltip.tsx";
|
} from "@components/UI/Tooltip.tsx";
|
||||||
import {
|
import { MessageState, useDevice, useNodeDB } from "@core/stores";
|
||||||
MessageState,
|
|
||||||
useDevice,
|
|
||||||
useMessageStore,
|
|
||||||
useNodeDB,
|
|
||||||
} from "@core/stores";
|
|
||||||
import type { Message } from "@core/stores/messageStore/types.ts";
|
import type { Message } from "@core/stores/messageStore/types.ts";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
import { type Protobuf, Types } from "@meshtastic/core";
|
import { type Protobuf, Types } from "@meshtastic/core";
|
||||||
@@ -53,8 +48,7 @@ interface MessageItemProps {
|
|||||||
|
|
||||||
export const MessageItem = ({ message }: MessageItemProps) => {
|
export const MessageItem = ({ message }: MessageItemProps) => {
|
||||||
const { config } = useDevice();
|
const { config } = useDevice();
|
||||||
const { getNode } = useNodeDB();
|
const { getNode, getMyNode } = useNodeDB();
|
||||||
const { getMyNodeNum } = useMessageStore();
|
|
||||||
const { t, i18n } = useTranslation("messages");
|
const { t, i18n } = useTranslation("messages");
|
||||||
|
|
||||||
const MESSAGE_STATUS_MAP = useMemo(
|
const MESSAGE_STATUS_MAP = useMemo(
|
||||||
@@ -102,7 +96,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
|
|||||||
return message.from != null ? getNode(message.from) : null;
|
return message.from != null ? getNode(message.from) : null;
|
||||||
}, [getNode, message.from]);
|
}, [getNode, message.from]);
|
||||||
|
|
||||||
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
|
const myNodeNum = useMemo(() => getMyNode().num, [getMyNode]);
|
||||||
|
|
||||||
const { displayName, shortName, isFavorite } = useMemo(() => {
|
const { displayName, shortName, isFavorite } = useMemo(() => {
|
||||||
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores";
|
||||||
|
import type { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
|
export function useNewNodeNum(
|
||||||
|
id: number,
|
||||||
|
nodeInfo: Protobuf.Mesh.MyNodeInfo,
|
||||||
|
): void {
|
||||||
|
useDeviceStore.getState().getDevice(id)?.setHardware(nodeInfo);
|
||||||
|
useNodeDBStore.getState().getNodeDB(id)?.setNodeNum(nodeInfo.myNodeNum);
|
||||||
|
useMessageStore
|
||||||
|
.getState()
|
||||||
|
.getMessageStore(id)
|
||||||
|
?.setNodeNum(nodeInfo.myNodeNum);
|
||||||
|
}
|
||||||
@@ -6,5 +6,6 @@ console.log(`Dev mode: ${isDev}`);
|
|||||||
if (isDev) {
|
if (isDev) {
|
||||||
featureFlags.setOverrides({
|
featureFlags.setOverrides({
|
||||||
persistNodeDB: true,
|
persistNodeDB: true,
|
||||||
|
persistMessages: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useDeviceContext } from "@app/core/stores/utils/useDeviceContext";
|
import { useDeviceContext } from "@core/hooks/useDeviceContext";
|
||||||
import { type Device, useDeviceStore } from "@core/stores/deviceStore";
|
import { type Device, useDeviceStore } from "@core/stores/deviceStore";
|
||||||
|
import { type MessageStore, useMessageStore } from "@core/stores/messageStore";
|
||||||
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CurrentDeviceContext,
|
CurrentDeviceContext,
|
||||||
type DeviceContext,
|
type DeviceContext,
|
||||||
useDeviceContext,
|
useDeviceContext,
|
||||||
} from "@app/core/stores/utils/useDeviceContext";
|
} from "@core/hooks/useDeviceContext";
|
||||||
export { useAppStore } from "@core/stores/appStore";
|
export { useAppStore } from "@core/stores/appStore";
|
||||||
export {
|
export {
|
||||||
type Device,
|
type Device,
|
||||||
@@ -19,9 +20,10 @@ export {
|
|||||||
MessageState,
|
MessageState,
|
||||||
type MessageStore,
|
type MessageStore,
|
||||||
MessageType,
|
MessageType,
|
||||||
useMessageStore, // TODO: Bring hook into this file
|
useMessageStore,
|
||||||
} from "@core/stores/messageStore";
|
} from "@core/stores/messageStore";
|
||||||
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
|
||||||
|
export type { NodeErrorType } from "@core/stores/nodeDBStore/types";
|
||||||
export {
|
export {
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
useSidebar, // TODO: Bring hook into this file
|
useSidebar, // TODO: Bring hook into this file
|
||||||
@@ -43,3 +45,11 @@ export const useDevice = (): Device => {
|
|||||||
);
|
);
|
||||||
return device;
|
return device;
|
||||||
};
|
};
|
||||||
|
export const useMessages = (): MessageStore => {
|
||||||
|
const { deviceId } = useDeviceContext();
|
||||||
|
|
||||||
|
const device = useMessageStore(
|
||||||
|
(s) => s.getMessageStore(deviceId) ?? s.addMessageStore(deviceId),
|
||||||
|
);
|
||||||
|
return device;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// import { storageWithMapSupport } from "../storage/indexDB.ts";
|
import { featureFlags } from "@core/services/featureFlags";
|
||||||
import type {
|
import type {
|
||||||
ChannelId,
|
ChannelId,
|
||||||
ClearMessageParams,
|
ClearMessageParams,
|
||||||
@@ -10,10 +10,16 @@ import type {
|
|||||||
NodeNum,
|
NodeNum,
|
||||||
SetMessageStateParams,
|
SetMessageStateParams,
|
||||||
} from "@core/stores/messageStore/types.ts";
|
} from "@core/stores/messageStore/types.ts";
|
||||||
|
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
||||||
|
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||||
import type { Types } from "@meshtastic/core";
|
import type { Types } from "@meshtastic/core";
|
||||||
// import { persist } from "zustand/middleware";
|
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { create } from "zustand";
|
import { create as createStore, type StateCreator } from "zustand";
|
||||||
|
import { type PersistOptions, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
const CURRENT_STORE_VERSION = 0;
|
||||||
|
const MESSAGESTORE_RETENTION_NUM = 10;
|
||||||
|
const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel
|
||||||
|
|
||||||
export enum MessageState {
|
export enum MessageState {
|
||||||
Ack = "ack",
|
Ack = "ack",
|
||||||
@@ -33,55 +39,126 @@ export function getConversationId(
|
|||||||
return [node1, node2].sort((a, b) => a - b).join(":");
|
return [node1, node2].sort((a, b) => a - b).join(":");
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageStore {
|
export interface MessageBuckets {
|
||||||
messages: {
|
direct: Map<ConversationId, MessageLogMap>;
|
||||||
direct: Map<ConversationId, MessageLogMap>;
|
broadcast: Map<ChannelId, MessageLogMap>;
|
||||||
broadcast: Map<ChannelId, MessageLogMap>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
export interface MessageStore {
|
export interface MessageStore {
|
||||||
messages: MessageStore["messages"];
|
id: number;
|
||||||
draft: Map<Types.Destination, string>;
|
myNodeNum: number | undefined;
|
||||||
nodeNum: number; // This device's node number
|
|
||||||
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
|
messages: MessageBuckets;
|
||||||
|
drafts: Map<Types.Destination, string>;
|
||||||
|
|
||||||
|
// Ephemeral UI state (not persisted)
|
||||||
|
activeChat: number;
|
||||||
chatType: MessageType;
|
chatType: MessageType;
|
||||||
|
|
||||||
setNodeNum: (nodeNum: number) => void;
|
setNodeNum: (nodeNum: number) => void;
|
||||||
getMyNodeNum: () => number;
|
|
||||||
saveMessage: (message: Message) => void;
|
saveMessage: (message: Message) => void;
|
||||||
setMessageState: (params: SetMessageStateParams) => void;
|
setMessageState: (params: SetMessageStateParams) => void;
|
||||||
getMessages: (params: GetMessagesParams) => Message[];
|
getMessages: (params: GetMessagesParams) => Message[];
|
||||||
|
|
||||||
getDraft: (key: Types.Destination) => string;
|
getDraft: (key: Types.Destination) => string;
|
||||||
setDraft: (key: Types.Destination, message: string) => void;
|
setDraft: (key: Types.Destination, message: string) => void;
|
||||||
|
clearDraft: (key: Types.Destination) => void;
|
||||||
|
//clearAllDrafts: (key: Types.Destination) => void;
|
||||||
|
|
||||||
deleteAllMessages: () => void;
|
deleteAllMessages: () => void;
|
||||||
clearMessageByMessageId: (params: ClearMessageParams) => void;
|
clearMessageByMessageId: (params: ClearMessageParams) => void;
|
||||||
clearDraft: (key: Types.Destination) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// const CURRENT_STORE_VERSION = 0;
|
export interface MessageStoreState {
|
||||||
|
addMessageStore: (id: number) => MessageStore;
|
||||||
|
removeMessageStore: (id: number) => void;
|
||||||
|
getMessageStore: (id: number) => MessageStore | undefined;
|
||||||
|
getMessageStores: () => MessageStore[];
|
||||||
|
}
|
||||||
|
interface PrivateMessageStoreState extends MessageStoreState {
|
||||||
|
messageStores: Map<number, MessageStore>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageStoreData = {
|
||||||
|
id: number;
|
||||||
|
myNodeNum: number | undefined;
|
||||||
|
|
||||||
|
messages: MessageBuckets;
|
||||||
|
drafts: Map<Types.Destination, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MessageStorePersisted = {
|
||||||
|
messageStores: Map<number, MessageStoreData>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function messageStoreFactory(
|
||||||
|
id: number,
|
||||||
|
get: () => PrivateMessageStoreState,
|
||||||
|
set: typeof useMessageStore.setState,
|
||||||
|
data?: Partial<MessageStoreData>,
|
||||||
|
): MessageStore {
|
||||||
|
const messages = data?.messages ?? {
|
||||||
|
direct: new Map<ConversationId, MessageLogMap>(),
|
||||||
|
broadcast: new Map<ChannelId, MessageLogMap>(),
|
||||||
|
};
|
||||||
|
const drafts = data?.drafts ?? new Map<Types.Destination, string>();
|
||||||
|
const myNodeNum = data?.myNodeNum;
|
||||||
|
const activeChat = 0;
|
||||||
|
const chatType = MessageType.Broadcast;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
myNodeNum,
|
||||||
|
messages,
|
||||||
|
drafts,
|
||||||
|
activeChat,
|
||||||
|
chatType,
|
||||||
|
|
||||||
export const useMessageStore = create<MessageStore>()(
|
|
||||||
// persist(
|
|
||||||
(set, get) => ({
|
|
||||||
messages: {
|
|
||||||
direct: new Map<ConversationId, MessageLogMap>(),
|
|
||||||
broadcast: new Map<ChannelId, MessageLogMap>(),
|
|
||||||
},
|
|
||||||
draft: new Map<number, string>(),
|
|
||||||
activeChat: 0,
|
|
||||||
chatType: MessageType.Broadcast,
|
|
||||||
nodeNum: 0,
|
|
||||||
setNodeNum: (nodeNum) => {
|
setNodeNum: (nodeNum) => {
|
||||||
set(
|
set(
|
||||||
produce((state: MessageStore) => {
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
state.nodeNum = nodeNum;
|
const newStore = draft.messageStores.get(id);
|
||||||
|
if (!newStore) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
newStore.myNodeNum = nodeNum;
|
||||||
|
|
||||||
|
for (const [otherId, oldStore] of draft.messageStores) {
|
||||||
|
if (otherId === id || oldStore.myNodeNum !== nodeNum) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adopt broadcast conversations (reuses inner Map references)
|
||||||
|
for (const [channelId, logMap] of oldStore.messages.broadcast) {
|
||||||
|
newStore.messages.broadcast.set(channelId, logMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adopt direct conversations
|
||||||
|
for (const [conversationId, logMap] of oldStore.messages.direct) {
|
||||||
|
newStore.messages.direct.set(conversationId, logMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adopt drafts
|
||||||
|
for (const [destination, draftText] of oldStore.drafts) {
|
||||||
|
newStore.drafts.set(destination, draftText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop old store
|
||||||
|
draft.messageStores.delete(otherId);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getMyNodeNum: () => get().nodeNum,
|
|
||||||
saveMessage: (message: Message) => {
|
saveMessage: (message: Message) => {
|
||||||
set(
|
set(
|
||||||
produce((state: MessageStore) => {
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
|
const state = draft.messageStores.get(id);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let log: MessageLogMap | undefined;
|
||||||
if (message.type === MessageType.Direct) {
|
if (message.type === MessageType.Direct) {
|
||||||
const conversationId = getConversationId(message.from, message.to);
|
const conversationId = getConversationId(message.from, message.to);
|
||||||
if (!state.messages.direct.has(conversationId)) {
|
if (!state.messages.direct.has(conversationId)) {
|
||||||
@@ -90,9 +167,9 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
new Map<MessageId, Message>(),
|
new Map<MessageId, Message>(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
state.messages.direct
|
|
||||||
.get(conversationId)
|
log = state.messages.direct.get(conversationId);
|
||||||
?.set(message.messageId, message);
|
log?.set(message.messageId, message);
|
||||||
} else if (message.type === MessageType.Broadcast) {
|
} else if (message.type === MessageType.Broadcast) {
|
||||||
const channelId = message.channel as ChannelId;
|
const channelId = message.channel as ChannelId;
|
||||||
if (!state.messages.broadcast.has(channelId)) {
|
if (!state.messages.broadcast.has(channelId)) {
|
||||||
@@ -101,9 +178,14 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
new Map<MessageId, Message>(),
|
new Map<MessageId, Message>(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
state.messages.broadcast
|
|
||||||
.get(channelId)
|
log = state.messages.broadcast.get(channelId);
|
||||||
?.set(message.messageId, message);
|
log?.set(message.messageId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log) {
|
||||||
|
// Enforce retention limit
|
||||||
|
evictOldestEntries(log, MESSAGELOG_RETENTION_NUM);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -111,7 +193,12 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
|
|
||||||
setMessageState: (params: SetMessageStateParams) => {
|
setMessageState: (params: SetMessageStateParams) => {
|
||||||
set(
|
set(
|
||||||
produce((state: MessageStore) => {
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
|
const state = draft.messageStores.get(id);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
let messageLog: MessageLogMap | undefined;
|
let messageLog: MessageLogMap | undefined;
|
||||||
let targetMessage: Message | undefined;
|
let targetMessage: Message | undefined;
|
||||||
|
|
||||||
@@ -144,8 +231,13 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getMessages: (params: GetMessagesParams): Message[] => {
|
getMessages: (params: GetMessagesParams): Message[] => {
|
||||||
const state = get();
|
const state = get().messageStores.get(id);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
let messageMap: MessageLogMap | undefined;
|
let messageMap: MessageLogMap | undefined;
|
||||||
|
|
||||||
if (params.type === MessageType.Direct) {
|
if (params.type === MessageType.Direct) {
|
||||||
@@ -164,9 +256,61 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
return messagesArray;
|
return messagesArray;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDraft: (key) => {
|
||||||
|
const state = get().messageStores.get(id);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.drafts.get(key) ?? "";
|
||||||
|
},
|
||||||
|
setDraft: (key, message) => {
|
||||||
|
set(
|
||||||
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
|
const state = draft.messageStores.get(id);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.drafts.set(key, message);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
clearDraft: (key) => {
|
||||||
|
set(
|
||||||
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
|
const state = draft.messageStores.get(id);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.drafts.delete(key);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAllMessages: () => {
|
||||||
|
set(
|
||||||
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
|
const state = draft.messageStores.get(id);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.messages.direct = new Map<ConversationId, MessageLogMap>();
|
||||||
|
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
clearMessageByMessageId: (params: ClearMessageParams) => {
|
clearMessageByMessageId: (params: ClearMessageParams) => {
|
||||||
set(
|
set(
|
||||||
produce((state: MessageStore) => {
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
|
const state = draft.messageStores.get(id);
|
||||||
|
if (!state) {
|
||||||
|
throw new Error(`No MessageStore found for id: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
let messageLog: MessageLogMap | undefined;
|
let messageLog: MessageLogMap | undefined;
|
||||||
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
|
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
|
||||||
let parentKey: ConversationId | ChannelId;
|
let parentKey: ConversationId | ChannelId;
|
||||||
@@ -176,6 +320,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
parentMap = state.messages.direct;
|
parentMap = state.messages.direct;
|
||||||
messageLog = parentMap.get(parentKey);
|
messageLog = parentMap.get(parentKey);
|
||||||
} else {
|
} else {
|
||||||
|
// Broadcast
|
||||||
parentKey = params.channelId;
|
parentKey = params.channelId;
|
||||||
parentMap = state.messages.broadcast;
|
parentMap = state.messages.broadcast;
|
||||||
messageLog = parentMap.get(parentKey);
|
messageLog = parentMap.get(parentKey);
|
||||||
@@ -206,39 +351,109 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getDraft: (key) => {
|
};
|
||||||
return get().draft.get(key) ?? "";
|
}
|
||||||
},
|
|
||||||
setDraft: (key, message) => {
|
export const messageStoreInitializer: StateCreator<PrivateMessageStoreState> = (
|
||||||
set(
|
set,
|
||||||
produce((state: MessageStore) => {
|
get,
|
||||||
state.draft.set(key, message);
|
) => ({
|
||||||
}),
|
messageStores: new Map(),
|
||||||
);
|
|
||||||
},
|
addMessageStore: (id) => {
|
||||||
clearDraft: (key) => {
|
const existing = get().messageStores.get(id);
|
||||||
set(
|
if (existing) {
|
||||||
produce((state: MessageStore) => {
|
return existing;
|
||||||
state.draft.delete(key);
|
}
|
||||||
}),
|
|
||||||
);
|
const nodeStore = messageStoreFactory(id, get, set);
|
||||||
},
|
set(
|
||||||
deleteAllMessages: () => {
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
set(
|
draft.messageStores.set(id, nodeStore);
|
||||||
produce((state: MessageStore) => {
|
|
||||||
state.messages.direct = new Map<ConversationId, MessageLogMap>();
|
// Enforce retention limit
|
||||||
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
|
evictOldestEntries(draft.messageStores, MESSAGESTORE_RETENTION_NUM);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
|
return nodeStore;
|
||||||
|
},
|
||||||
|
removeMessageStore: (id) => {
|
||||||
|
set(
|
||||||
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
|
draft.messageStores.delete(id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getMessageStores: () => Array.from(get().messageStores.values()),
|
||||||
|
getMessageStore: (id) => get().messageStores.get(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistOptions: PersistOptions<
|
||||||
|
PrivateMessageStoreState,
|
||||||
|
MessageStorePersisted
|
||||||
|
> = {
|
||||||
|
name: "meshtastic-MessageStore-store",
|
||||||
|
storage: createStorage<MessageStorePersisted>(),
|
||||||
|
version: CURRENT_STORE_VERSION,
|
||||||
|
partialize: (s): MessageStorePersisted => ({
|
||||||
|
messageStores: new Map(
|
||||||
|
Array.from(s.messageStores.entries()).map(([id, db]) => [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
id: db.id,
|
||||||
|
myNodeNum: db.myNodeNum,
|
||||||
|
messages: db.messages,
|
||||||
|
drafts: db.drafts,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
// {
|
onRehydrateStorage: () => (state) => {
|
||||||
// name: 'meshtastic-message-store',
|
if (!state) {
|
||||||
// storage: storageWithMapSupport,
|
return;
|
||||||
// version: CURRENT_STORE_VERSION,
|
}
|
||||||
// partialize: (state) => ({
|
console.debug(
|
||||||
// messages: state.messages,
|
"MessageStoreStore: Rehydrating state with ",
|
||||||
// nodeNum: state.nodeNum,
|
state.messageStores.size,
|
||||||
// }),
|
" MessageStores -",
|
||||||
// })
|
state.messageStores,
|
||||||
|
);
|
||||||
|
|
||||||
|
useMessageStore.setState(
|
||||||
|
produce<PrivateMessageStoreState>((draft) => {
|
||||||
|
const rebuilt = new Map<number, MessageStore>();
|
||||||
|
for (const [id, data] of (
|
||||||
|
draft.messageStores as unknown as Map<number, MessageStoreData>
|
||||||
|
).entries()) {
|
||||||
|
if (data.myNodeNum !== undefined) {
|
||||||
|
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
|
||||||
|
rebuilt.set(
|
||||||
|
id,
|
||||||
|
messageStoreFactory(
|
||||||
|
id,
|
||||||
|
useMessageStore.getState,
|
||||||
|
useMessageStore.setState,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draft.messageStores = rebuilt;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add persist middleware on the store if the feature flag is enabled
|
||||||
|
const persistMessages = featureFlags.get("persistMessages");
|
||||||
|
console.debug(
|
||||||
|
`MessageStore: Persisting messages is ${persistMessages ? "enabled" : "disabled"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const useMessageStore = persistMessages
|
||||||
|
? createStore<
|
||||||
|
PrivateMessageStoreState,
|
||||||
|
[["zustand/persist", MessageStorePersisted]]
|
||||||
|
>(persist(messageStoreInitializer, persistOptions))
|
||||||
|
: createStore<PrivateMessageStoreState>()(messageStoreInitializer);
|
||||||
|
|||||||
@@ -1,34 +1,43 @@
|
|||||||
|
/** biome-ignore-all lint/style/noNonNullAssertion: <tests> */
|
||||||
import { Types } from "@meshtastic/core";
|
import { Types } from "@meshtastic/core";
|
||||||
|
import { setAutoFreeze } from "immer";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import { getConversationId, MessageState, MessageType } from "./index.ts";
|
||||||
getConversationId,
|
import type { ChannelId, Message } from "./types.ts";
|
||||||
MessageState,
|
|
||||||
MessageType,
|
|
||||||
useMessageStore,
|
|
||||||
} from "./index.ts";
|
|
||||||
import type {
|
|
||||||
ChannelId,
|
|
||||||
ConversationId,
|
|
||||||
Message,
|
|
||||||
MessageLogMap,
|
|
||||||
} from "./types.ts";
|
|
||||||
|
|
||||||
vi.mock("../utils/indexDB.ts", () => {
|
const idbMem = new Map<string, string>();
|
||||||
const memoryStorage: Record<string, string> = {};
|
vi.mock("idb-keyval", () => ({
|
||||||
return {
|
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
|
||||||
storageWithMapSupport: {
|
set: vi.fn((key: string, val: string) => {
|
||||||
getItem: vi.fn(async (name: string): Promise<string | null> => {
|
idbMem.set(key, val);
|
||||||
return (await memoryStorage[name]) ?? null;
|
return Promise.resolve();
|
||||||
}),
|
}),
|
||||||
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
|
del: vi.fn((k: string) => {
|
||||||
memoryStorage[name] = await value;
|
idbMem.delete(k);
|
||||||
}),
|
return Promise.resolve();
|
||||||
removeItem: vi.fn(async (name: string): Promise<void> => {
|
}),
|
||||||
await delete memoryStorage[name];
|
}));
|
||||||
}),
|
|
||||||
|
async function freshStore(persist = false) {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
// suppress console output from the store during tests (for github actions)
|
||||||
|
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Mock feature flag for persistence
|
||||||
|
vi.doMock("@core/services/featureFlags", () => ({
|
||||||
|
featureFlags: {
|
||||||
|
get: vi.fn((key: string) =>
|
||||||
|
key === "persistMessages" ? persist : false,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
const mod = await import("./index.ts");
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
const myNodeNum = 111;
|
const myNodeNum = 111;
|
||||||
const otherNodeNum1 = 222;
|
const otherNodeNum1 = 222;
|
||||||
@@ -90,53 +99,56 @@ const broadcastMessage2: Message = {
|
|||||||
message: "Broadcast message 2",
|
message: "Broadcast message 2",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("useMessageStore", () => {
|
describe("MessageStore persistence & rehydrate", () => {
|
||||||
const initialState = useMessageStore.getState();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useMessageStore.setState(
|
idbMem.clear();
|
||||||
{
|
vi.clearAllMocks();
|
||||||
...initialState,
|
});
|
||||||
messages: {
|
|
||||||
direct: new Map<ConversationId, MessageLogMap>(),
|
it("should have correct initial state", async () => {
|
||||||
broadcast: new Map<ChannelId, MessageLogMap>(),
|
const { useMessageStore } = await freshStore();
|
||||||
},
|
const state = useMessageStore.getState();
|
||||||
draft: new Map<Types.Destination, string>(),
|
const store = state.addMessageStore(123);
|
||||||
},
|
|
||||||
true,
|
expect(store.messages.direct).toBeInstanceOf(Map);
|
||||||
|
expect(store.messages.direct.size).toBe(0);
|
||||||
|
expect(store.messages.broadcast).toBeInstanceOf(Map);
|
||||||
|
expect(store.messages.broadcast.size).toBe(0);
|
||||||
|
expect(store.drafts).toBeInstanceOf(Map);
|
||||||
|
expect(store.drafts.size).toBe(0);
|
||||||
|
expect(store.myNodeNum).toBe(undefined);
|
||||||
|
expect(store.activeChat).toBe(0);
|
||||||
|
expect(store.chatType).toBe(MessageType.Broadcast);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set nodeNum", async () => {
|
||||||
|
const { useMessageStore } = await freshStore();
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
const db = state.addMessageStore(123);
|
||||||
|
|
||||||
|
db.setNodeNum(myNodeNum);
|
||||||
|
expect(useMessageStore.getState().getMessageStore(123)?.myNodeNum).toBe(
|
||||||
|
myNodeNum,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should have correct initial state", () => {
|
describe("saveMessage", async () => {
|
||||||
|
const { useMessageStore } = await freshStore();
|
||||||
const state = useMessageStore.getState();
|
const state = useMessageStore.getState();
|
||||||
expect(state.messages.direct).toBeInstanceOf(Map);
|
state.addMessageStore(123);
|
||||||
expect(state.messages.direct.size).toBe(0);
|
|
||||||
expect(state.messages.broadcast).toBeInstanceOf(Map);
|
|
||||||
expect(state.messages.broadcast.size).toBe(0);
|
|
||||||
expect(state.draft).toBeInstanceOf(Map);
|
|
||||||
expect(state.draft.size).toBe(0);
|
|
||||||
expect(state.nodeNum).toBe(0);
|
|
||||||
expect(state.activeChat).toBe(0);
|
|
||||||
expect(state.chatType).toBe(MessageType.Broadcast);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should set nodeNum", () => {
|
|
||||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
|
||||||
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("saveMessage", () => {
|
|
||||||
it("should save a direct message with correct Map structure", () => {
|
it("should save a direct message with correct Map structure", () => {
|
||||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||||
const state = useMessageStore.getState();
|
|
||||||
const conversationId = getConversationId(
|
const conversationId = getConversationId(
|
||||||
directMessageToOther1.from,
|
directMessageToOther1.from,
|
||||||
directMessageToOther1.to,
|
directMessageToOther1.to,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const store = state.getMessageStore(123)!;
|
||||||
|
|
||||||
// Check if the conversation Map exists
|
// Check if the conversation Map exists
|
||||||
expect(state.messages.direct.has(conversationId)).toBe(true);
|
expect(store.messages.direct.has(conversationId)).toBe(true);
|
||||||
const conversationLog = state.messages.direct.get(conversationId);
|
const conversationLog = store.messages.direct.get(conversationId);
|
||||||
// Check if the inner Map (MessageLogMap) exists and is a Map
|
// Check if the inner Map (MessageLogMap) exists and is a Map
|
||||||
expect(conversationLog).toBeInstanceOf(Map);
|
expect(conversationLog).toBeInstanceOf(Map);
|
||||||
// Check if the message exists within the inner Map
|
// Check if the message exists within the inner Map
|
||||||
@@ -148,12 +160,12 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should save a broadcast message with correct Map structure", () => {
|
it("should save a broadcast message with correct Map structure", () => {
|
||||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||||
const state = useMessageStore.getState();
|
const store = state.getMessageStore(123)!;
|
||||||
const channelId = broadcastMessage1.channel;
|
const channelId = broadcastMessage1.channel;
|
||||||
|
|
||||||
expect(state.messages.broadcast.has(channelId)).toBe(true);
|
expect(store.messages.broadcast.has(channelId)).toBe(true);
|
||||||
const channelLog = state.messages.broadcast.get(channelId);
|
const channelLog = store.messages.broadcast.get(channelId);
|
||||||
expect(channelLog).toBeInstanceOf(Map);
|
expect(channelLog).toBeInstanceOf(Map);
|
||||||
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
|
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
|
||||||
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(
|
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(
|
||||||
@@ -162,46 +174,50 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should save multiple messages correctly", () => {
|
it("should save multiple messages correctly", () => {
|
||||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
|
||||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||||
|
|
||||||
const state = useMessageStore.getState();
|
const store = state.getMessageStore(123)!;
|
||||||
|
|
||||||
const convId1 = getConversationId(myNodeNum, otherNodeNum1);
|
const convId1 = getConversationId(myNodeNum, otherNodeNum1);
|
||||||
expect(
|
expect(
|
||||||
state.messages.direct
|
store.messages.direct
|
||||||
.get(convId1)
|
.get(convId1)
|
||||||
?.get(directMessageToOther1.messageId),
|
?.get(directMessageToOther1.messageId),
|
||||||
).toEqual(directMessageToOther1);
|
).toEqual(directMessageToOther1);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
state.messages.direct
|
store.messages.direct
|
||||||
.get(convId1)
|
.get(convId1)
|
||||||
?.get(directMessageFromOther1.messageId),
|
?.get(directMessageFromOther1.messageId),
|
||||||
).toEqual(directMessageFromOther1);
|
).toEqual(directMessageFromOther1);
|
||||||
|
|
||||||
const channelId = broadcastMessage1.channel;
|
const channelId = broadcastMessage1.channel;
|
||||||
expect(
|
expect(
|
||||||
state.messages.broadcast
|
store.messages.broadcast
|
||||||
.get(channelId)
|
.get(channelId)
|
||||||
?.get(broadcastMessage1.messageId),
|
?.get(broadcastMessage1.messageId),
|
||||||
).toEqual(broadcastMessage1);
|
).toEqual(broadcastMessage1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getMessages", () => {
|
describe("getMessages", async () => {
|
||||||
|
const { useMessageStore } = await freshStore();
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
state.addMessageStore(123);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
state.getMessageStore(123)?.setNodeNum(myNodeNum);
|
||||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
|
||||||
useMessageStore.getState().saveMessage(directMessageToOther2);
|
state.getMessageStore(123)?.saveMessage(directMessageToOther2);
|
||||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||||
useMessageStore.getState().saveMessage(broadcastMessage2);
|
state.getMessageStore(123)?.saveMessage(broadcastMessage2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return broadcast messages for a channel, sorted by date", () => {
|
it("should return broadcast messages for a channel, sorted by date", () => {
|
||||||
const messages = useMessageStore.getState().getMessages({
|
const messages = state.getMessageStore(123)!.getMessages({
|
||||||
type: MessageType.Broadcast,
|
type: MessageType.Broadcast,
|
||||||
channelId: broadcastChannel,
|
channelId: broadcastChannel,
|
||||||
});
|
});
|
||||||
@@ -211,7 +227,7 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty array for broadcast if channel has no messages", () => {
|
it("should return empty array for broadcast if channel has no messages", () => {
|
||||||
const messages = useMessageStore.getState().getMessages({
|
const messages = state.getMessageStore(123)!.getMessages({
|
||||||
type: MessageType.Broadcast,
|
type: MessageType.Broadcast,
|
||||||
channelId: Types.ChannelNumber.Channel1,
|
channelId: Types.ChannelNumber.Channel1,
|
||||||
});
|
});
|
||||||
@@ -219,7 +235,7 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return combined direct messages for a specific chat pair, sorted by date", () => {
|
it("should return combined direct messages for a specific chat pair, sorted by date", () => {
|
||||||
const messages = useMessageStore.getState().getMessages({
|
const messages = state.getMessageStore(123)!.getMessages({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: myNodeNum,
|
nodeA: myNodeNum,
|
||||||
nodeB: otherNodeNum1,
|
nodeB: otherNodeNum1,
|
||||||
@@ -230,7 +246,7 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return only relevant direct messages for a different chat pair", () => {
|
it("should return only relevant direct messages for a different chat pair", () => {
|
||||||
const messages = useMessageStore.getState().getMessages({
|
const messages = state.getMessageStore(123)!.getMessages({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: myNodeNum,
|
nodeA: myNodeNum,
|
||||||
nodeB: otherNodeNum2,
|
nodeB: otherNodeNum2,
|
||||||
@@ -240,7 +256,7 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty array for direct chat if no messages exist", () => {
|
it("should return empty array for direct chat if no messages exist", () => {
|
||||||
const messages = useMessageStore.getState().getMessages({
|
const messages = state.getMessageStore(123)!.getMessages({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: myNodeNum,
|
nodeA: myNodeNum,
|
||||||
nodeB: 999,
|
nodeB: 999,
|
||||||
@@ -249,16 +265,20 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setMessageState", () => {
|
describe("setMessageState", async () => {
|
||||||
|
const { useMessageStore } = await freshStore();
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
state.addMessageStore(123);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
state.getMessageStore(123)?.setNodeNum(myNodeNum);
|
||||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
|
||||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update state for a direct message", () => {
|
it("should update state for a direct message", () => {
|
||||||
useMessageStore.getState().setMessageState({
|
state.getMessageStore(123)!.setMessageState({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: directMessageToOther1.from,
|
nodeA: directMessageToOther1.from,
|
||||||
nodeB: directMessageToOther1.to,
|
nodeB: directMessageToOther1.to,
|
||||||
@@ -271,13 +291,14 @@ describe("useMessageStore", () => {
|
|||||||
);
|
);
|
||||||
const message = useMessageStore
|
const message = useMessageStore
|
||||||
.getState()
|
.getState()
|
||||||
|
.getMessageStore(123)!
|
||||||
.messages.direct.get(conversationId)
|
.messages.direct.get(conversationId)
|
||||||
?.get(directMessageToOther1.messageId);
|
?.get(directMessageToOther1.messageId);
|
||||||
expect(message?.state).toBe(MessageState.Ack);
|
expect(message?.state).toBe(MessageState.Ack);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update state for another direct message in the same conversation", () => {
|
it("should update state for another direct message in the same conversation", () => {
|
||||||
useMessageStore.getState().setMessageState({
|
state.getMessageStore(123)!.setMessageState({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: directMessageFromOther1.from,
|
nodeA: directMessageFromOther1.from,
|
||||||
nodeB: directMessageFromOther1.to,
|
nodeB: directMessageFromOther1.to,
|
||||||
@@ -290,13 +311,14 @@ describe("useMessageStore", () => {
|
|||||||
);
|
);
|
||||||
const message = useMessageStore
|
const message = useMessageStore
|
||||||
.getState()
|
.getState()
|
||||||
|
.getMessageStore(123)!
|
||||||
.messages.direct.get(conversationId)
|
.messages.direct.get(conversationId)
|
||||||
?.get(directMessageFromOther1.messageId);
|
?.get(directMessageFromOther1.messageId);
|
||||||
expect(message?.state).toBe(MessageState.Failed);
|
expect(message?.state).toBe(MessageState.Failed);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update state for a broadcast message", () => {
|
it("should update state for a broadcast message", () => {
|
||||||
useMessageStore.getState().setMessageState({
|
state.getMessageStore(123)!.setMessageState({
|
||||||
type: MessageType.Broadcast,
|
type: MessageType.Broadcast,
|
||||||
channelId: broadcastChannel,
|
channelId: broadcastChannel,
|
||||||
messageId: broadcastMessage1.messageId,
|
messageId: broadcastMessage1.messageId,
|
||||||
@@ -304,6 +326,7 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
const message = useMessageStore
|
const message = useMessageStore
|
||||||
.getState()
|
.getState()
|
||||||
|
.getMessageStore(123)!
|
||||||
.messages.broadcast.get(broadcastChannel)
|
.messages.broadcast.get(broadcastChannel)
|
||||||
?.get(broadcastMessage1.messageId);
|
?.get(broadcastMessage1.messageId);
|
||||||
expect(message?.state).toBe(MessageState.Ack);
|
expect(message?.state).toBe(MessageState.Ack);
|
||||||
@@ -311,7 +334,7 @@ describe("useMessageStore", () => {
|
|||||||
|
|
||||||
it("should warn if message is not found (direct)", () => {
|
it("should warn if message is not found (direct)", () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
useMessageStore.getState().setMessageState({
|
state.getMessageStore(123)!.setMessageState({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: myNodeNum,
|
nodeA: myNodeNum,
|
||||||
nodeB: otherNodeNum1,
|
nodeB: otherNodeNum1,
|
||||||
@@ -328,7 +351,7 @@ describe("useMessageStore", () => {
|
|||||||
|
|
||||||
it("should warn if message is not found (broadcast)", () => {
|
it("should warn if message is not found (broadcast)", () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
useMessageStore.getState().setMessageState({
|
state.getMessageStore(123)!.setMessageState({
|
||||||
type: MessageType.Broadcast,
|
type: MessageType.Broadcast,
|
||||||
channelId: broadcastChannel,
|
channelId: broadcastChannel,
|
||||||
messageId: 999,
|
messageId: 999,
|
||||||
@@ -344,7 +367,7 @@ describe("useMessageStore", () => {
|
|||||||
|
|
||||||
it("should warn if conversation/channel is not found", () => {
|
it("should warn if conversation/channel is not found", () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
useMessageStore.getState().setMessageState({
|
state.getMessageStore(123)!.setMessageState({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: myNodeNum,
|
nodeA: myNodeNum,
|
||||||
nodeB: 998,
|
nodeB: 998,
|
||||||
@@ -360,14 +383,18 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clearMessageByMessageId", () => {
|
describe("clearMessageByMessageId", async () => {
|
||||||
|
const { useMessageStore } = await freshStore();
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
state.addMessageStore(123);
|
||||||
|
|
||||||
const extraDirectMessageId = 1011;
|
const extraDirectMessageId = 1011;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
state.getMessageStore(123)?.setNodeNum(myNodeNum);
|
||||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
state.getMessageStore(123)?.saveMessage(directMessageFromOther1);
|
||||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||||
useMessageStore.getState().saveMessage({
|
state.getMessageStore(123)?.saveMessage({
|
||||||
...directMessageToOther1,
|
...directMessageToOther1,
|
||||||
messageId: extraDirectMessageId,
|
messageId: extraDirectMessageId,
|
||||||
date: Date.now() + 50,
|
date: Date.now() + 50,
|
||||||
@@ -380,21 +407,21 @@ describe("useMessageStore", () => {
|
|||||||
const nodeB = directMessageToOther1.to;
|
const nodeB = directMessageToOther1.to;
|
||||||
const conversationId = getConversationId(nodeA, nodeB);
|
const conversationId = getConversationId(nodeA, nodeB);
|
||||||
|
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: nodeA,
|
nodeA: nodeA,
|
||||||
nodeB: nodeB,
|
nodeB: nodeB,
|
||||||
messageId: messageIdToDelete,
|
messageId: messageIdToDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useMessageStore.getState();
|
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||||
const conversationLog = state.messages.direct.get(conversationId);
|
const conversationLog = store.messages.direct.get(conversationId);
|
||||||
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
|
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
|
||||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
||||||
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(
|
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(state.messages.direct.has(conversationId)).toBe(true);
|
expect(store.messages.direct.has(conversationId)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should delete another specific direct message", () => {
|
it("should delete another specific direct message", () => {
|
||||||
@@ -403,15 +430,15 @@ describe("useMessageStore", () => {
|
|||||||
const nodeB = directMessageFromOther1.to;
|
const nodeB = directMessageFromOther1.to;
|
||||||
const conversationId = getConversationId(nodeA, nodeB);
|
const conversationId = getConversationId(nodeA, nodeB);
|
||||||
|
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: nodeA,
|
nodeA: nodeA,
|
||||||
nodeB: nodeB,
|
nodeB: nodeB,
|
||||||
messageId: messageIdToDelete,
|
messageId: messageIdToDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useMessageStore.getState();
|
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||||
const conversationLog = state.messages.direct.get(conversationId);
|
const conversationLog = store.messages.direct.get(conversationId);
|
||||||
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
|
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
|
||||||
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
|
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
|
||||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
||||||
@@ -421,15 +448,15 @@ describe("useMessageStore", () => {
|
|||||||
const messageIdToDelete = broadcastMessage1.messageId;
|
const messageIdToDelete = broadcastMessage1.messageId;
|
||||||
const channelId = broadcastMessage1.channel;
|
const channelId = broadcastMessage1.channel;
|
||||||
|
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Broadcast,
|
type: MessageType.Broadcast,
|
||||||
channelId: channelId,
|
channelId: channelId,
|
||||||
messageId: messageIdToDelete,
|
messageId: messageIdToDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = useMessageStore.getState();
|
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||||
expect(
|
expect(
|
||||||
state.messages.broadcast.get(channelId)?.get(messageIdToDelete),
|
store.messages.broadcast.get(channelId)?.get(messageIdToDelete),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -440,37 +467,37 @@ describe("useMessageStore", () => {
|
|||||||
);
|
);
|
||||||
const broadcastChanId = broadcastMessage1.channel;
|
const broadcastChanId = broadcastMessage1.channel;
|
||||||
|
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: directMessageToOther1.from,
|
nodeA: directMessageToOther1.from,
|
||||||
nodeB: directMessageToOther1.to,
|
nodeB: directMessageToOther1.to,
|
||||||
messageId: directMessageToOther1.messageId,
|
messageId: directMessageToOther1.messageId,
|
||||||
});
|
});
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: directMessageFromOther1.from,
|
nodeA: directMessageFromOther1.from,
|
||||||
nodeB: directMessageFromOther1.to,
|
nodeB: directMessageFromOther1.to,
|
||||||
messageId: directMessageFromOther1.messageId,
|
messageId: directMessageFromOther1.messageId,
|
||||||
});
|
});
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: directMessageToOther1.from,
|
nodeA: directMessageToOther1.from,
|
||||||
nodeB: directMessageToOther1.to,
|
nodeB: directMessageToOther1.to,
|
||||||
messageId: extraDirectMessageId,
|
messageId: extraDirectMessageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(
|
expect(
|
||||||
false,
|
state.getMessageStore(123)?.messages.direct.has(directConvId),
|
||||||
);
|
).toBe(false);
|
||||||
|
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Broadcast,
|
type: MessageType.Broadcast,
|
||||||
channelId: broadcastChanId,
|
channelId: broadcastChanId,
|
||||||
messageId: broadcastMessage1.messageId,
|
messageId: broadcastMessage1.messageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
useMessageStore.getState().messages.broadcast.has(broadcastChanId),
|
state.getMessageStore(123)?.messages.broadcast.has(broadcastChanId),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -479,7 +506,7 @@ describe("useMessageStore", () => {
|
|||||||
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
|
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: myNodeNum,
|
nodeA: myNodeNum,
|
||||||
nodeB: otherNodeNum1,
|
nodeB: otherNodeNum1,
|
||||||
@@ -487,8 +514,8 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
|
|
||||||
const state = useMessageStore.getState();
|
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||||
const conversationLog = state.messages.direct.get(conversationId);
|
const conversationLog = store.messages.direct.get(conversationId);
|
||||||
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
|
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("not found in direct chat"),
|
expect.stringContaining("not found in direct chat"),
|
||||||
@@ -500,7 +527,7 @@ describe("useMessageStore", () => {
|
|||||||
it("should not error when trying to delete from non-existent conversation/channel", () => {
|
it("should not error when trying to delete from non-existent conversation/channel", () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
expect(() => {
|
expect(() => {
|
||||||
useMessageStore.getState().clearMessageByMessageId({
|
state.getMessageStore(123)?.clearMessageByMessageId({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: myNodeNum,
|
nodeA: myNodeNum,
|
||||||
nodeB: 9998,
|
nodeB: 9998,
|
||||||
@@ -518,63 +545,241 @@ describe("useMessageStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Drafts", () => {
|
describe("Drafts", async () => {
|
||||||
|
const { useMessageStore } = await freshStore();
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
state.addMessageStore(123);
|
||||||
|
|
||||||
const draftKeyDirect = otherNodeNum1;
|
const draftKeyDirect = otherNodeNum1;
|
||||||
const draftKeyBroadcast = broadcastChannel;
|
const draftKeyBroadcast = broadcastChannel;
|
||||||
const draftMessage = "This is a draft";
|
const draftMessage = "This is a draft";
|
||||||
|
|
||||||
it("should set and get a draft for direct chat", () => {
|
it("should set and get a draft for direct chat", () => {
|
||||||
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
|
state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage);
|
||||||
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(
|
expect(state.getMessageStore(123)?.drafts.get(draftKeyDirect)).toBe(
|
||||||
draftMessage,
|
draftMessage,
|
||||||
);
|
);
|
||||||
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(
|
expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe(
|
||||||
draftMessage,
|
draftMessage,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set and get a draft for broadcast chat", () => {
|
it("should set and get a draft for broadcast chat", () => {
|
||||||
useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage);
|
state.getMessageStore(123)?.setDraft(draftKeyBroadcast, draftMessage);
|
||||||
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(
|
expect(state.getMessageStore(123)?.drafts.get(draftKeyBroadcast)).toBe(
|
||||||
draftMessage,
|
draftMessage,
|
||||||
);
|
);
|
||||||
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(
|
expect(state.getMessageStore(123)?.getDraft(draftKeyBroadcast)).toBe(
|
||||||
draftMessage,
|
draftMessage,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty string for non-existent draft", () => {
|
it("should return empty string for non-existent draft", () => {
|
||||||
expect(useMessageStore.getState().getDraft(999)).toBe("");
|
expect(state.getMessageStore(123)?.getDraft(999)).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should clear a draft", () => {
|
it("should clear a draft", () => {
|
||||||
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
|
state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage);
|
||||||
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true);
|
expect(state.getMessageStore(123)?.drafts.has(draftKeyDirect)).toBe(true);
|
||||||
useMessageStore.getState().clearDraft(draftKeyDirect);
|
state.getMessageStore(123)?.clearDraft(draftKeyDirect);
|
||||||
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false);
|
expect(state.getMessageStore(123)?.drafts.has(draftKeyDirect)).toBe(
|
||||||
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe("");
|
false,
|
||||||
|
);
|
||||||
|
expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteAllMessages", () => {
|
describe("deleteAllMessages", async () => {
|
||||||
it("should clear all direct and broadcast messages, leaving empty Maps", () => {
|
const { useMessageStore } = await freshStore();
|
||||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
const state = useMessageStore.getState();
|
||||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
state.addMessageStore(123);
|
||||||
|
|
||||||
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(
|
it("should clear all direct and broadcast messages, leaving empty Maps", () => {
|
||||||
|
state.getMessageStore(123)?.saveMessage(directMessageToOther1);
|
||||||
|
state.getMessageStore(123)?.saveMessage(broadcastMessage1);
|
||||||
|
|
||||||
|
expect(state.getMessageStore(123)?.messages.direct.size).toBeGreaterThan(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
useMessageStore.getState().messages.broadcast.size,
|
state.getMessageStore(123)?.messages.broadcast.size,
|
||||||
).toBeGreaterThan(0);
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
useMessageStore.getState().deleteAllMessages();
|
state.getMessageStore(123)?.deleteAllMessages();
|
||||||
|
|
||||||
|
const store = useMessageStore.getState().getMessageStore(123)!;
|
||||||
|
expect(store.messages.direct).toBeInstanceOf(Map);
|
||||||
|
expect(store.messages.direct.size).toBe(0);
|
||||||
|
expect(store.messages.broadcast).toBeInstanceOf(Map);
|
||||||
|
expect(store.messages.broadcast.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("persistence", () => {
|
||||||
|
it("partialize persists data; onRehydrateStorage rebuilds methods (messages + drafts survive)", async () => {
|
||||||
|
{
|
||||||
|
const { useMessageStore } = await freshStore(true);
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
|
||||||
|
const store = state.addMessageStore(123);
|
||||||
|
store.setNodeNum(321);
|
||||||
|
|
||||||
|
const convId = getConversationId(myNodeNum, otherNodeNum1);
|
||||||
|
store.saveMessage(directMessageToOther1);
|
||||||
|
store.saveMessage(broadcastMessage1);
|
||||||
|
store.setDraft(
|
||||||
|
otherNodeNum1 as unknown as Types.Destination,
|
||||||
|
"draft-text",
|
||||||
|
);
|
||||||
|
|
||||||
|
const store2 = state.addMessageStore(123);
|
||||||
|
|
||||||
|
expect(store2.messages.direct.has(convId)).toBe(true);
|
||||||
|
expect(store2.messages.direct.get(convId)?.has(101)).toBe(true);
|
||||||
|
expect(store2.messages.broadcast.get(broadcastChannel)?.has(201)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
store2.getDraft(otherNodeNum1 as unknown as Types.Destination),
|
||||||
|
).toBe("draft-text");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { useMessageStore } = await freshStore(true);
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
|
||||||
|
const store = state.getMessageStore(123)!; // rebuilt instance
|
||||||
|
expect(store).toBeTruthy();
|
||||||
|
|
||||||
|
// Methods should work after rehydrate
|
||||||
|
const directMsgs = store.getMessages({
|
||||||
|
type: MessageType.Direct,
|
||||||
|
nodeA: myNodeNum,
|
||||||
|
nodeB: otherNodeNum1,
|
||||||
|
});
|
||||||
|
expect(directMsgs.map((m) => m.messageId)).toEqual([101]);
|
||||||
|
|
||||||
|
const bMsgs = store.getMessages({
|
||||||
|
type: MessageType.Broadcast,
|
||||||
|
channelId: broadcastChannel,
|
||||||
|
});
|
||||||
|
expect(bMsgs.map((m) => m.messageId)).toEqual([201]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
store.getDraft(otherNodeNum1 as unknown as Types.Destination),
|
||||||
|
).toBe("draft-text");
|
||||||
|
|
||||||
|
store.saveMessage(directMessageFromOther1);
|
||||||
|
|
||||||
|
const after = store.getMessages({
|
||||||
|
type: MessageType.Direct,
|
||||||
|
nodeA: myNodeNum,
|
||||||
|
nodeB: otherNodeNum1,
|
||||||
|
});
|
||||||
|
expect(after.map((m) => m.messageId)).toEqual([101, 102]);
|
||||||
|
expect(after[1]?.state).toBe(MessageState.Waiting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeMessageStore persists removal across reload", async () => {
|
||||||
|
{
|
||||||
|
const { useMessageStore } = await freshStore(true);
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
const store = state.addMessageStore(99);
|
||||||
|
|
||||||
|
store.setNodeNum(42);
|
||||||
|
expect(state.getMessageStore(99)).toBeDefined();
|
||||||
|
|
||||||
|
state.removeMessageStore(99);
|
||||||
|
expect(state.getMessageStore(99)).toBeUndefined();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { useMessageStore } = await freshStore(true);
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
expect(state.getMessageStore(99)).toBeUndefined(); // still gone
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rehydrate only rebuilds stores with myNodeNum set (orphans dropped)", async () => {
|
||||||
|
{
|
||||||
|
const { useMessageStore } = await freshStore(true);
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
|
||||||
|
// Orphan (no myNodeNum)
|
||||||
|
const orphan = state.addMessageStore(500);
|
||||||
|
orphan.saveMessage(broadcastMessage1);
|
||||||
|
|
||||||
|
// Proper store
|
||||||
|
const good = state.addMessageStore(501);
|
||||||
|
good.setNodeNum(777);
|
||||||
|
good.saveMessage(broadcastMessage2);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { useMessageStore } = await freshStore(true);
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
|
||||||
|
expect(state.getMessageStore(500)).toBeUndefined(); // orphan dropped
|
||||||
|
const kept = state.getMessageStore(501);
|
||||||
|
expect(kept).toBeDefined();
|
||||||
|
|
||||||
|
expect(kept?.messages.broadcast.get(broadcastChannel)?.has(202)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evicts the earliest-added message store when exceeding cap of 10", async () => {
|
||||||
|
const { useMessageStore } = await freshStore();
|
||||||
const state = useMessageStore.getState();
|
const state = useMessageStore.getState();
|
||||||
expect(state.messages.direct).toBeInstanceOf(Map);
|
|
||||||
expect(state.messages.direct.size).toBe(0);
|
for (let i = 1; i <= 10; i++) {
|
||||||
expect(state.messages.broadcast).toBeInstanceOf(Map);
|
state.addMessageStore(i);
|
||||||
expect(state.messages.broadcast.size).toBe(0);
|
}
|
||||||
|
// Adding the 11th should evict the earliest (id=1)
|
||||||
|
state.addMessageStore(11);
|
||||||
|
|
||||||
|
expect(state.getMessageStore(1)).toBeUndefined(); // evicted
|
||||||
|
expect(state.getMessageStore(2)).toBeDefined(); // still there
|
||||||
|
expect(state.getMessageStore(11)).toBeDefined(); // newest kept
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the latest 1000 messages in a broadcast channel (oldest trimmed)", async () => {
|
||||||
|
setAutoFreeze(false); // Disable immer auto-freeze for performance in this test
|
||||||
|
try {
|
||||||
|
const { useMessageStore, MessageType, MessageState } =
|
||||||
|
await freshStore();
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
|
||||||
|
const store = state.addMessageStore(123);
|
||||||
|
|
||||||
|
const channelId = 0 as number;
|
||||||
|
const total = 1005;
|
||||||
|
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
store.saveMessage({
|
||||||
|
type: MessageType.Broadcast,
|
||||||
|
from: 123,
|
||||||
|
to: 0xffffffff,
|
||||||
|
channel: channelId,
|
||||||
|
date: Date.now() + i,
|
||||||
|
messageId: i,
|
||||||
|
state: MessageState.Waiting,
|
||||||
|
message: `m${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = useMessageStore.getState().getMessageStore(123)!;
|
||||||
|
const log = fresh.messages.broadcast.get(channelId)!;
|
||||||
|
|
||||||
|
expect(log.size).toBe(1000); // capped
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
expect(log.has(i)).toBe(false); // oldest removed
|
||||||
|
}
|
||||||
|
for (let i = 6; i <= 1005; i++) {
|
||||||
|
expect(log.has(i)).toBe(true); // newest kept
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAutoFreeze(true); // Restore immer auto-freeze
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { featureFlags } from "@core/services/featureFlags";
|
import { featureFlags } from "@core/services/featureFlags";
|
||||||
|
import { validateIncomingNode } from "@core/stores/nodeDBStore/nodeValidation";
|
||||||
|
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
||||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||||
import { Protobuf, type Types } from "@meshtastic/core";
|
import { Protobuf, type Types } from "@meshtastic/core";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
@@ -33,6 +35,7 @@ export interface NodeDB {
|
|||||||
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
|
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
|
||||||
getNodes: (
|
getNodes: (
|
||||||
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||||
|
includeSelf?: boolean,
|
||||||
) => Protobuf.Mesh.NodeInfo[];
|
) => Protobuf.Mesh.NodeInfo[];
|
||||||
getMyNode: () => Protobuf.Mesh.NodeInfo;
|
getMyNode: () => Protobuf.Mesh.NodeInfo;
|
||||||
|
|
||||||
@@ -85,7 +88,21 @@ function nodeDBFactory(
|
|||||||
if (!nodeDB) {
|
if (!nodeDB) {
|
||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
throw new Error(`No nodeDB found (id: ${id})`);
|
||||||
}
|
}
|
||||||
nodeDB.nodeMap.set(node.num, node);
|
// Use validation to check the new node before adding
|
||||||
|
const next = validateIncomingNode(
|
||||||
|
node,
|
||||||
|
(nodeNum: number, err: NodeErrorType) => {
|
||||||
|
nodeDB.setNodeError(nodeNum, err);
|
||||||
|
},
|
||||||
|
(filter?: (node: Protobuf.Mesh.NodeInfo) => boolean) =>
|
||||||
|
nodeDB.getNodes(filter, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
// Validation failed and error has been set inside validateIncomingNode
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodeDB.nodeMap.set(node.num, next);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -165,7 +182,7 @@ function nodeDBFactory(
|
|||||||
}
|
}
|
||||||
const node = nodeDB.nodeMap.get(data.from);
|
const node = nodeDB.nodeMap.get(data.from);
|
||||||
if (node) {
|
if (node) {
|
||||||
node.lastHeard = data.time;
|
node.lastHeard = data.time > 0 ? data.time : Date.now(); // fallback to now if time is 0 or negative
|
||||||
node.snr = data.snr;
|
node.snr = data.snr;
|
||||||
nodeDB.nodeMap.set(data.from, node);
|
nodeDB.nodeMap.set(data.from, node);
|
||||||
} else {
|
} else {
|
||||||
@@ -173,7 +190,7 @@ function nodeDBFactory(
|
|||||||
data.from,
|
data.from,
|
||||||
create(Protobuf.Mesh.NodeInfoSchema, {
|
create(Protobuf.Mesh.NodeInfoSchema, {
|
||||||
num: data.from,
|
num: data.from,
|
||||||
lastHeard: data.time,
|
lastHeard: data.time > 0 ? data.time : Date.now(), // fallback to now if time is 0 or negative,
|
||||||
snr: data.snr,
|
snr: data.snr,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -225,21 +242,46 @@ function nodeDBFactory(
|
|||||||
|
|
||||||
for (const [key, oldDB] of draft.nodeDBs) {
|
for (const [key, oldDB] of draft.nodeDBs) {
|
||||||
if (key === id) {
|
if (key === id) {
|
||||||
|
// short-circuit self
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (oldDB.myNodeNum === nodeNum) {
|
if (oldDB.myNodeNum === nodeNum) {
|
||||||
// The new DB is typically empty when nodenum is set, so we can safely copy over from the old DB
|
// We found the oldDB (same myNodeNum). Merge node-by-node as if the new nodes are added with addNode
|
||||||
// otherwise, discard the old DB completely
|
|
||||||
if (newDB.nodeMap.size === 0) {
|
const mergedNodes = new Map(oldDB.nodeMap);
|
||||||
newDB.nodeMap = oldDB.nodeMap;
|
const mergedErrors = new Map(oldDB.nodeErrors);
|
||||||
newDB.nodeErrors = oldDB.nodeErrors;
|
|
||||||
} else {
|
const getNodesProxy = (
|
||||||
console.error(
|
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||||
`NodeDB with id: ${id} already has nodes, not merging with old DB`,
|
): Protobuf.Mesh.NodeInfo[] => {
|
||||||
|
const arr = Array.from(mergedNodes.values());
|
||||||
|
return filter ? arr.filter(filter) : arr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setErrorProxy = (nodeNum: number, err: NodeErrorType) => {
|
||||||
|
mergedErrors.set(nodeNum, { error: err } as NodeError);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [nodeNum, newNode] of newDB.nodeMap) {
|
||||||
|
const next = validateIncomingNode(
|
||||||
|
newNode,
|
||||||
|
setErrorProxy,
|
||||||
|
getNodesProxy,
|
||||||
);
|
);
|
||||||
|
if (next) {
|
||||||
|
mergedNodes.set(nodeNum, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = newDB.getNodeError(nodeNum);
|
||||||
|
if (err && !oldDB.hasNodeError(nodeNum)) {
|
||||||
|
mergedErrors.set(nodeNum, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
draft.nodeDBs.delete(key);
|
// finalize: move maps into newDB and drop oldDB entry
|
||||||
|
newDB.nodeMap = mergedNodes;
|
||||||
|
newDB.nodeErrors = mergedErrors;
|
||||||
|
draft.nodeDBs.delete(oldDB.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -291,14 +333,15 @@ function nodeDBFactory(
|
|||||||
return nodeDB.nodeMap.get(nodeNum);
|
return nodeDB.nodeMap.get(nodeNum);
|
||||||
},
|
},
|
||||||
|
|
||||||
getNodes: (filter) => {
|
getNodes: (filter, includeSelf) => {
|
||||||
const nodeDB = get().nodeDBs.get(id);
|
const nodeDB = get().nodeDBs.get(id);
|
||||||
if (!nodeDB) {
|
if (!nodeDB) {
|
||||||
throw new Error(`No nodeDB found (id: ${id})`);
|
throw new Error(`No nodeDB found (id: ${id})`);
|
||||||
}
|
}
|
||||||
const all = Array.from(nodeDB.nodeMap.values()).filter(
|
const all = Array.from(nodeDB.nodeMap.values()).filter((n) =>
|
||||||
(n) => n.num !== nodeDB.myNodeNum,
|
includeSelf ? true : n.num !== nodeDB.myNodeNum,
|
||||||
);
|
);
|
||||||
|
|
||||||
return filter ? all.filter(filter) : all;
|
return filter ? all.filter(filter) : all;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -351,13 +394,8 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (
|
|||||||
produce<PrivateNodeDBState>((draft) => {
|
produce<PrivateNodeDBState>((draft) => {
|
||||||
draft.nodeDBs.set(id, nodeDB);
|
draft.nodeDBs.set(id, nodeDB);
|
||||||
|
|
||||||
// If over limit, remove oldest inserted. FIFO
|
// Enforce retention limit
|
||||||
if (draft.nodeDBs.size > NODEDB_RETENTION_NUM) {
|
evictOldestEntries(draft.nodeDBs, NODEDB_RETENTION_NUM);
|
||||||
const firstKey = draft.nodeDBs.keys().next().value;
|
|
||||||
if (firstKey !== undefined) {
|
|
||||||
draft.nodeDBs.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noExplicitAny: <tests> */
|
||||||
|
/** biome-ignore-all lint/style/noNonNullAssertion: <tests> */
|
||||||
|
import { create } from "@bufbuild/protobuf";
|
||||||
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { toByteArray } from "base64-js";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const idbMem = new Map<string, string>();
|
const idbMem = new Map<string, string>();
|
||||||
@@ -14,27 +19,32 @@ vi.mock("idb-keyval", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// import a fresh copy of the store module (because the store is created at import time)
|
// import a fresh copy of the store module (because the store is created at import time)
|
||||||
async function freshStore() {
|
async function freshStore(persist = false) {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
|
// suppress console output from the store during tests (for github actions)
|
||||||
|
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||||
|
|
||||||
|
vi.doMock("@core/services/featureFlags", () => ({
|
||||||
|
featureFlags: {
|
||||||
|
get: vi.fn((key: string) => (key === "persistNodeDB" ? persist : false)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const mod = await import("./index.ts");
|
const mod = await import("./index.ts");
|
||||||
return mod;
|
return mod;
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("@core/services/featureFlags", () => {
|
|
||||||
return {
|
|
||||||
featureFlags: {
|
|
||||||
get: vi.fn((key: string) => {
|
|
||||||
if (key === "persistNodeDB") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeNode(num: number, extras: Record<string, any> = {}) {
|
function makeNode(num: number, extras: Record<string, any> = {}) {
|
||||||
return { num, ...extras } as any;
|
return create(Protobuf.Mesh.NodeInfoSchema, { num, ...extras });
|
||||||
|
}
|
||||||
|
function makeUser(fields: Record<string, any>) {
|
||||||
|
return create(Protobuf.Mesh.UserSchema, fields);
|
||||||
|
}
|
||||||
|
function makePosition(fields: Record<string, any>) {
|
||||||
|
return create(Protobuf.Mesh.PositionSchema, fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("NodeDB store", () => {
|
describe("NodeDB store", () => {
|
||||||
@@ -85,6 +95,10 @@ describe("NodeDB store", () => {
|
|||||||
db.processPacket({ from: 50, time: 2222, snr: 9 } as any);
|
db.processPacket({ from: 50, time: 2222, snr: 9 } as any);
|
||||||
expect(db.getNode(50)?.lastHeard).toBe(2222);
|
expect(db.getNode(50)?.lastHeard).toBe(2222);
|
||||||
expect(db.getNode(50)?.snr).toBe(9);
|
expect(db.getNode(50)?.snr).toBe(9);
|
||||||
|
|
||||||
|
db.processPacket({ from: 50, time: 0, snr: 9 } as any);
|
||||||
|
expect(db.getNode(50)?.lastHeard).toBeCloseTo(Date.now(), -1); // within 10ms
|
||||||
|
expect(db.getNode(50)?.snr).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("addUser and addPosition updates existing or creates new nodes", async () => {
|
it("addUser and addPosition updates existing or creates new nodes", async () => {
|
||||||
@@ -145,27 +159,9 @@ describe("NodeDB store", () => {
|
|||||||
expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" });
|
expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setNodeNum does not merge when new DB already has nodes; old is removed", async () => {
|
|
||||||
const { useNodeDBStore } = await freshStore();
|
|
||||||
const st = useNodeDBStore.getState();
|
|
||||||
|
|
||||||
const oldDB = st.addNodeDB(10);
|
|
||||||
oldDB.setNodeNum(999);
|
|
||||||
oldDB.addNode(makeNode(200)); // old has data
|
|
||||||
|
|
||||||
const newDB = st.addNodeDB(11);
|
|
||||||
newDB.addNode(makeNode(300)); // new has data -> no merge
|
|
||||||
newDB.setNodeNum(999);
|
|
||||||
|
|
||||||
expect(st.getNodeDB(10)).toBeUndefined(); // old removed
|
|
||||||
// new kept its own nodes; did not copy old's
|
|
||||||
expect(newDB.getNode(300)).toBeTruthy();
|
|
||||||
expect(newDB.getNode(200)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => {
|
it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => {
|
||||||
{
|
{
|
||||||
const { useNodeDBStore } = await freshStore();
|
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||||
const st = useNodeDBStore.getState();
|
const st = useNodeDBStore.getState();
|
||||||
const db = st.addNodeDB(123);
|
const db = st.addNodeDB(123);
|
||||||
db.setNodeNum(321);
|
db.setNodeNum(321);
|
||||||
@@ -173,7 +169,7 @@ describe("NodeDB store", () => {
|
|||||||
db.setNodeError(50, "ERROR" as any);
|
db.setNodeError(50, "ERROR" as any);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const { useNodeDBStore } = await freshStore();
|
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||||
const st = useNodeDBStore.getState();
|
const st = useNodeDBStore.getState();
|
||||||
const db = st.getNodeDB(123)!;
|
const db = st.getNodeDB(123)!;
|
||||||
|
|
||||||
@@ -213,7 +209,7 @@ describe("NodeDB store", () => {
|
|||||||
|
|
||||||
it("removeNodeDB persists removal across reload", async () => {
|
it("removeNodeDB persists removal across reload", async () => {
|
||||||
{
|
{
|
||||||
const { useNodeDBStore } = await freshStore();
|
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||||
const st = useNodeDBStore.getState();
|
const st = useNodeDBStore.getState();
|
||||||
st.addNodeDB(99);
|
st.addNodeDB(99);
|
||||||
expect(st.getNodeDB(99)).toBeDefined();
|
expect(st.getNodeDB(99)).toBeDefined();
|
||||||
@@ -221,7 +217,7 @@ describe("NodeDB store", () => {
|
|||||||
expect(st.getNodeDB(99)).toBeUndefined();
|
expect(st.getNodeDB(99)).toBeUndefined();
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const { useNodeDBStore } = await freshStore();
|
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||||
const st = useNodeDBStore.getState();
|
const st = useNodeDBStore.getState();
|
||||||
expect(st.getNodeDB(99)).toBeUndefined(); // still gone
|
expect(st.getNodeDB(99)).toBeUndefined(); // still gone
|
||||||
}
|
}
|
||||||
@@ -229,7 +225,7 @@ describe("NodeDB store", () => {
|
|||||||
|
|
||||||
it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => {
|
it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => {
|
||||||
{
|
{
|
||||||
const { useNodeDBStore } = await freshStore();
|
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||||
const st = useNodeDBStore.getState();
|
const st = useNodeDBStore.getState();
|
||||||
|
|
||||||
const orphan = st.addNodeDB(500); // no setNodeNum
|
const orphan = st.addNodeDB(500); // no setNodeNum
|
||||||
@@ -240,7 +236,7 @@ describe("NodeDB store", () => {
|
|||||||
good.addNode(makeNode(2));
|
good.addNode(makeNode(2));
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const { useNodeDBStore } = await freshStore();
|
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||||
const st = useNodeDBStore.getState();
|
const st = useNodeDBStore.getState();
|
||||||
expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped
|
expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped
|
||||||
expect(st.getNodeDB(501)).toBeDefined(); // kept
|
expect(st.getNodeDB(501)).toBeDefined(); // kept
|
||||||
@@ -259,3 +255,199 @@ describe("NodeDB store", () => {
|
|||||||
expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/);
|
expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NodeDB – merge semantics, PKI checks & extras", () => {
|
||||||
|
const keyOld = toByteArray("40g5tLC6A+tXE92EyhwVwdiKsXwa1QUjZjkzEi0pCy4=");
|
||||||
|
const keyNew = toByteArray("osxYoEP43oDeWZyjyKx1wz/5cvwEOthHB6AhO2fXEQg=");
|
||||||
|
|
||||||
|
it("upserts node", async () => {
|
||||||
|
const { useNodeDBStore } = await freshStore();
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
|
||||||
|
const oldDB = st.addNodeDB(10);
|
||||||
|
oldDB.setNodeNum(999);
|
||||||
|
oldDB.addNode(makeNode(200, { position: { altitude: 100 } }));
|
||||||
|
|
||||||
|
const newDB = st.addNodeDB(11);
|
||||||
|
newDB.addNode(makeNode(300));
|
||||||
|
newDB.addNode(makeNode(200, { position: { altitude: 120 } }));
|
||||||
|
newDB.setNodeNum(999);
|
||||||
|
|
||||||
|
expect(st.getNodeDB(10)).toBeUndefined(); // old db removed
|
||||||
|
expect(newDB.getNode(300)).toBeTruthy(); // node kept
|
||||||
|
const n200 = newDB.getNode(200)!;
|
||||||
|
expect(n200.position?.altitude).toBe(120); // replace existing
|
||||||
|
});
|
||||||
|
|
||||||
|
it("key conflict: keep old node, flag error", async () => {
|
||||||
|
const { useNodeDBStore } = await freshStore();
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
|
||||||
|
const oldDB = st.addNodeDB(20);
|
||||||
|
oldDB.setNodeNum(42);
|
||||||
|
oldDB.addNode(
|
||||||
|
makeNode(7, {
|
||||||
|
user: makeUser({ publicKey: keyOld, longName: "old-7" }),
|
||||||
|
position: makePosition({ latitudeI: 11, longitudeI: 22 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const newDB = st.addNodeDB(21);
|
||||||
|
newDB.addNode(
|
||||||
|
makeNode(7, {
|
||||||
|
user: makeUser({ publicKey: keyNew, longName: "new-7" }),
|
||||||
|
position: makePosition({ latitudeI: 33 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
newDB.setNodeNum(42);
|
||||||
|
|
||||||
|
const n7 = newDB.getNode(7)!;
|
||||||
|
|
||||||
|
// node from old
|
||||||
|
expect(n7.user?.longName).toBe("old-7");
|
||||||
|
expect(n7.user?.publicKey).toEqual(keyOld);
|
||||||
|
expect(n7.position?.latitudeI).toBe(11);
|
||||||
|
expect(n7.position?.longitudeI).toBe(22);
|
||||||
|
|
||||||
|
// error flagged
|
||||||
|
const err = newDB.getNodeError(7);
|
||||||
|
expect(err).toBeTruthy();
|
||||||
|
expect(String(err!.error)).toMatch(/MISMATCH|PK/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty new key; drop new node", async () => {
|
||||||
|
const { useNodeDBStore } = await freshStore();
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
|
||||||
|
const oldDB = st.addNodeDB(30);
|
||||||
|
oldDB.setNodeNum(77);
|
||||||
|
oldDB.addNode(
|
||||||
|
makeNode(5, { user: { publicKey: keyOld, longName: "old-5" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const newDB = st.addNodeDB(31);
|
||||||
|
newDB.addNode(
|
||||||
|
makeNode(5, { user: { publicKey: new Uint8Array(), longName: "new-5" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
newDB.setNodeNum(77);
|
||||||
|
|
||||||
|
// node from old
|
||||||
|
const n5 = newDB.getNode(5)!;
|
||||||
|
expect(n5.user?.publicKey).toEqual(keyOld); // keep old PK
|
||||||
|
expect(n5.user?.longName).toBe("old-5");
|
||||||
|
|
||||||
|
// error flagged
|
||||||
|
const err = newDB!.getNodeError(5);
|
||||||
|
expect(String(err!.error)).toMatch(/MISMATCH|PK/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("old key empty, new key present, store new node", async () => {
|
||||||
|
const { useNodeDBStore } = await freshStore();
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
|
||||||
|
const oldDB = st.addNodeDB(40);
|
||||||
|
oldDB.setNodeNum(1001);
|
||||||
|
oldDB.addNode(makeNode(8, { user: { longName: "old-8" } })); // no key
|
||||||
|
|
||||||
|
const newDB = st.addNodeDB(41);
|
||||||
|
newDB.addNode(
|
||||||
|
makeNode(8, {
|
||||||
|
user: { publicKey: keyNew, longName: "new-8" },
|
||||||
|
position: { altitude: 555 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
newDB.setNodeNum(1001);
|
||||||
|
|
||||||
|
// node from new
|
||||||
|
const n8 = newDB.getNode(8)!;
|
||||||
|
expect(n8.user?.longName).toBe("new-8");
|
||||||
|
expect(n8.user?.publicKey).toEqual(keyNew);
|
||||||
|
expect(n8.position?.altitude).toBe(555);
|
||||||
|
|
||||||
|
// no error
|
||||||
|
const err = newDB.getNodeError(8);
|
||||||
|
expect(err).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unions nodeErrors: preserves old and new, respects existing-on-conflict", async () => {
|
||||||
|
const { useNodeDBStore } = await freshStore();
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
|
||||||
|
const oldDB = st.addNodeDB(50);
|
||||||
|
oldDB.setNodeNum(2020);
|
||||||
|
oldDB.addNode(makeNode(1, { user: { longName: "old-1" } }));
|
||||||
|
oldDB.setNodeError(1, "OLD_ERR" as any);
|
||||||
|
|
||||||
|
const newDB = st.addNodeDB(51);
|
||||||
|
newDB.addNode(makeNode(1, { user: { longName: "new-1" } }));
|
||||||
|
newDB.addNode(makeNode(2, { user: { longName: "new-2" } }));
|
||||||
|
newDB.setNodeError(2, "NEW_ERR" as any);
|
||||||
|
|
||||||
|
// also set overlapping error
|
||||||
|
newDB.setNodeError(1, "SHOULD_NOT_OVERWRITE" as any);
|
||||||
|
|
||||||
|
newDB.setNodeNum(2020);
|
||||||
|
|
||||||
|
expect(newDB.getNodeError(1)!.error).toBe("OLD_ERR"); // old kept
|
||||||
|
expect(newDB.getNodeError(2)!.error).toBe("NEW_ERR"); // new added
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eviction still honors cap after merge", async () => {
|
||||||
|
const { useNodeDBStore } = await freshStore();
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
st.addNodeDB(i);
|
||||||
|
}
|
||||||
|
const oldDB = st.addNodeDB(100);
|
||||||
|
oldDB.setNodeNum(12345);
|
||||||
|
oldDB.addNode(makeNode(2000));
|
||||||
|
|
||||||
|
const newDB = st.addNodeDB(101);
|
||||||
|
newDB.setNodeNum(12345); // merges + deletes 100
|
||||||
|
|
||||||
|
// adding another to trigger eviction of earliest non-merged entry (which was 1)
|
||||||
|
st.addNodeDB(102);
|
||||||
|
|
||||||
|
expect(st.getNodeDB(1)).toBeUndefined(); // evicted
|
||||||
|
expect(st.getNodeDB(101)).toBeDefined(); // merged entry exists
|
||||||
|
expect(st.getNodeDB(101)!.getNode(2000)).toBeTruthy(); // carried over
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeAllNodes (optionally keeping my node) and removeAllNodeErrors persist across reload", async () => {
|
||||||
|
{
|
||||||
|
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
const db = st.addNodeDB(1000);
|
||||||
|
db.setNodeNum(55);
|
||||||
|
db.addNode(makeNode(55, { user: { longName: "me" } }));
|
||||||
|
db.addNode(makeNode(56));
|
||||||
|
db.setNodeError(56, "ERR" as any);
|
||||||
|
db.removeAllNodes(true);
|
||||||
|
db.removeAllNodeErrors();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { useNodeDBStore } = await freshStore(true); // with persistence
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
const db = st.getNodeDB(1000)!;
|
||||||
|
expect(db.getNode(55)).toBeTruthy(); // kept me
|
||||||
|
expect(db.getNode(56)).toBeUndefined(); // cleared others
|
||||||
|
expect(db.getNodeError(56)).toBeUndefined(); // cleared errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMyNode works after merge establishes myNodeNum", async () => {
|
||||||
|
const { useNodeDBStore } = await freshStore();
|
||||||
|
const st = useNodeDBStore.getState();
|
||||||
|
|
||||||
|
const oldDB = st.addNodeDB(1100);
|
||||||
|
oldDB.setNodeNum(4242);
|
||||||
|
oldDB.addNode(makeNode(4242));
|
||||||
|
|
||||||
|
const newDB = st.addNodeDB(1101);
|
||||||
|
newDB.setNodeNum(4242);
|
||||||
|
|
||||||
|
expect(newDB.getMyNode().num).toBe(4242);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { NodeErrorType } from "@core/stores";
|
||||||
|
import type { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
|
// Validates a new incoming node against existing nodes.
|
||||||
|
// If valid, returns a node to store, else returns undefined.
|
||||||
|
export function validateIncomingNode(
|
||||||
|
newNode: Protobuf.Mesh.NodeInfo,
|
||||||
|
setNodeError: (nodeNum: number, error: NodeErrorType) => void,
|
||||||
|
getNodes: (
|
||||||
|
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||||
|
) => Protobuf.Mesh.NodeInfo[],
|
||||||
|
): Protobuf.Mesh.NodeInfo | undefined {
|
||||||
|
const num = newNode.num;
|
||||||
|
const existingNodes = getNodes((node) => node.num === num);
|
||||||
|
|
||||||
|
if (existingNodes.length === 0) {
|
||||||
|
// No existing node with this node number.
|
||||||
|
// Check if the new node's public key (if present and not empty)
|
||||||
|
// is already claimed by another existing node.
|
||||||
|
if (
|
||||||
|
newNode.user?.publicKey !== undefined &&
|
||||||
|
newNode.user?.publicKey.length > 0
|
||||||
|
) {
|
||||||
|
const nodesWithSameKey = getNodes(
|
||||||
|
(node) => node.user?.publicKey === newNode.user?.publicKey,
|
||||||
|
);
|
||||||
|
if (nodesWithSameKey.length > 0) {
|
||||||
|
// This is a potential impersonation attempt.
|
||||||
|
setNodeError(num, "DUPLICATE_PKI");
|
||||||
|
return undefined; // drop newNode entirely
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newNode; // No conflicts, accept newNode
|
||||||
|
} else if (existingNodes.length === 1) {
|
||||||
|
// One existing node with this node number.
|
||||||
|
const oldNode = existingNodes[0];
|
||||||
|
if (!oldNode) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A public key is considered matching if the incoming key equals
|
||||||
|
// the existing key, OR if the existing key is empty.
|
||||||
|
const isKeyMatchingOrExistingEmpty =
|
||||||
|
oldNode.user?.publicKey === newNode.user?.publicKey ||
|
||||||
|
oldNode.user?.publicKey === undefined ||
|
||||||
|
oldNode.user?.publicKey.length === 0;
|
||||||
|
|
||||||
|
if (isKeyMatchingOrExistingEmpty) {
|
||||||
|
// Keys match or existing key was empty: trust the incoming node data completely.
|
||||||
|
// This allows for legitimate updates to user info and other fields.
|
||||||
|
return newNode;
|
||||||
|
} else {
|
||||||
|
// Keys do not match and existing key was not empty: potential impersonation attempt.
|
||||||
|
setNodeError(num, "MISMATCH_PKI");
|
||||||
|
return oldNode; // drop newNode fields and return old
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple existing nodes with the same node number
|
||||||
|
// This should never happen, but if it does, we drop the new node entirely.
|
||||||
|
setNodeError(num, "DUPLICATE_PKI");
|
||||||
|
return undefined; // drop newNode entirely
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Protobuf } from "@meshtastic/core";
|
import type { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI";
|
type NodeErrorType =
|
||||||
|
| Protobuf.Mesh.Routing_Error
|
||||||
|
| "MISMATCH_PKI"
|
||||||
|
| "DUPLICATE_PKI";
|
||||||
|
|
||||||
type NodeError = {
|
type NodeError = {
|
||||||
node: number;
|
node: number;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function evictOldestEntries<K, V>(
|
||||||
|
map: Map<K, V>,
|
||||||
|
maxSize: number,
|
||||||
|
): void {
|
||||||
|
// while loop in case maxSize is ever changed to be lower, to trim all the way down
|
||||||
|
while (map.size > maxSize) {
|
||||||
|
const firstKey = map.keys().next().value; // maps keep insertion order, so this is oldest
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
map.delete(firstKey);
|
||||||
|
} else {
|
||||||
|
break; // should not happen, but just in case
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ensureDefaultUser } from "@core/dto/NodeNumToNodeInfoDTO.ts";
|
import { ensureDefaultUser } from "@core/dto/NodeNumToNodeInfoDTO.ts";
|
||||||
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||||
|
import { useNewNodeNum } from "@core/hooks/useNewNodeNum";
|
||||||
import {
|
import {
|
||||||
type Device,
|
type Device,
|
||||||
type MessageStore,
|
type MessageStore,
|
||||||
@@ -7,8 +8,6 @@ import {
|
|||||||
type NodeDB,
|
type NodeDB,
|
||||||
} from "@core/stores";
|
} from "@core/stores";
|
||||||
import { type MeshDevice, Protobuf } from "@meshtastic/core";
|
import { type MeshDevice, Protobuf } from "@meshtastic/core";
|
||||||
import { fromByteArray } from "base64-js";
|
|
||||||
|
|
||||||
export const subscribeAll = (
|
export const subscribeAll = (
|
||||||
device: Device,
|
device: Device,
|
||||||
connection: MeshDevice,
|
connection: MeshDevice,
|
||||||
@@ -57,9 +56,7 @@ export const subscribeAll = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||||
device.setHardware(nodeInfo);
|
useNewNodeNum(device.id, nodeInfo);
|
||||||
messageStore.setNodeNum(nodeInfo.myNodeNum);
|
|
||||||
nodeDB.setNodeNum(nodeInfo.myNodeNum);
|
|
||||||
myNodeNum = nodeInfo.myNodeNum;
|
myNodeNum = nodeInfo.myNodeNum;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,24 +71,7 @@ export const subscribeAll = (
|
|||||||
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
|
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
|
||||||
const nodeWithUser = ensureDefaultUser(nodeInfo);
|
const nodeWithUser = ensureDefaultUser(nodeInfo);
|
||||||
|
|
||||||
if (nodeWithUser.num !== myNodeNum && nodeDB.getNode(nodeWithUser.num)) {
|
// PKI sanity check is handled inside nodeDB.addNode
|
||||||
const oldPublicKey = fromByteArray(
|
|
||||||
nodeDB.getNode(nodeWithUser.num)?.user?.publicKey ?? new Uint8Array(),
|
|
||||||
);
|
|
||||||
const newPublicKey = fromByteArray(
|
|
||||||
nodeWithUser.user?.publicKey ?? new Uint8Array(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (oldPublicKey !== newPublicKey) {
|
|
||||||
console.warn(
|
|
||||||
`Node ${nodeWithUser.user?.longName} (${nodeWithUser.num}) has a different public key than expected: Expected ${oldPublicKey} but got ${newPublicKey}`,
|
|
||||||
);
|
|
||||||
nodeDB.setNodeError(nodeWithUser.num, "MISMATCH_PKI");
|
|
||||||
|
|
||||||
// TODO: Handle this error case properly (refactor PKI dialog?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeDB.addNode(nodeWithUser);
|
nodeDB.addNode(nodeWithUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
MessageState,
|
MessageState,
|
||||||
MessageType,
|
MessageType,
|
||||||
useDevice,
|
useDevice,
|
||||||
useMessageStore,
|
useMessages,
|
||||||
useNodeDB,
|
useNodeDB,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@core/stores";
|
} from "@core/stores";
|
||||||
@@ -44,9 +44,9 @@ function SelectMessageChat() {
|
|||||||
|
|
||||||
export const MessagesPage = () => {
|
export const MessagesPage = () => {
|
||||||
const { channels, getUnreadCount, resetUnread, connection } = useDevice();
|
const { channels, getUnreadCount, resetUnread, connection } = useDevice();
|
||||||
const { getNodes, getNode, hasNodeError } = useNodeDB();
|
const { getNodes, getNode, getMyNode, hasNodeError } = useNodeDB();
|
||||||
|
|
||||||
const { getMyNodeNum, getMessages, setMessageState } = useMessageStore();
|
const { getMessages, setMessageState } = useMessages();
|
||||||
|
|
||||||
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
|
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
|
||||||
|
|
||||||
@@ -77,7 +77,10 @@ export const MessagesPage = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!type && !chatId && filteredChannels.length > 0) {
|
if (!type && !chatId && filteredChannels.length > 0) {
|
||||||
const defaultChannel = filteredChannels[0];
|
const defaultChannel = filteredChannels[0];
|
||||||
navigateToChat(MessageType.Broadcast, defaultChannel.index.toString());
|
navigateToChat(
|
||||||
|
MessageType.Broadcast,
|
||||||
|
defaultChannel?.index.toString() ?? "0",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [type, chatId, filteredChannels, navigateToChat]);
|
}, [type, chatId, filteredChannels, navigateToChat]);
|
||||||
|
|
||||||
@@ -138,7 +141,7 @@ export const MessagesPage = () => {
|
|||||||
} else {
|
} else {
|
||||||
setMessageState({
|
setMessageState({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: getMyNodeNum(),
|
nodeA: getMyNode().num,
|
||||||
nodeB: numericChatId,
|
nodeB: numericChatId,
|
||||||
messageId,
|
messageId,
|
||||||
newState: MessageState.Ack,
|
newState: MessageState.Ack,
|
||||||
@@ -160,7 +163,7 @@ export const MessagesPage = () => {
|
|||||||
} else {
|
} else {
|
||||||
setMessageState({
|
setMessageState({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: getMyNodeNum(),
|
nodeA: getMyNode().num,
|
||||||
nodeB: numericChatId,
|
nodeB: numericChatId,
|
||||||
messageId: failedId,
|
messageId: failedId,
|
||||||
newState: MessageState.Failed,
|
newState: MessageState.Failed,
|
||||||
@@ -168,14 +171,7 @@ export const MessagesPage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[numericChatId, chatType, connection, getMyNode, setMessageState, isDirect],
|
||||||
numericChatId,
|
|
||||||
chatType,
|
|
||||||
connection,
|
|
||||||
getMyNodeNum,
|
|
||||||
setMessageState,
|
|
||||||
isDirect,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderChatContent = () => {
|
const renderChatContent = () => {
|
||||||
@@ -194,7 +190,7 @@ export const MessagesPage = () => {
|
|||||||
<ChannelChat
|
<ChannelChat
|
||||||
messages={getMessages({
|
messages={getMessages({
|
||||||
type: MessageType.Direct,
|
type: MessageType.Direct,
|
||||||
nodeA: getMyNodeNum(),
|
nodeA: getMyNode().num,
|
||||||
nodeB: numericChatId,
|
nodeB: numericChatId,
|
||||||
}).reverse()}
|
}).reverse()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user