diff --git a/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx b/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx index 2f7c83af..8486c8b3 100644 --- a/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx +++ b/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx @@ -1,10 +1,13 @@ 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 { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@core/stores", () => ({ - useMessageStore: vi.fn(() => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useMessages: vi.fn(() => ({ deleteAllMessages: vi.fn(), })), })); @@ -17,11 +20,14 @@ describe("DeleteMessagesDialog", () => { mockOnOpenChange.mockClear(); mockClearAllMessages.mockClear(); - const mockedUseMessageStore = vi.mocked(useMessageStore); - mockedUseMessageStore.mockImplementation(() => ({ - deleteAllMessages: mockClearAllMessages, - })); - mockedUseMessageStore.mockClear(); + const mockedUseMessages = vi.mocked(useMessages); + mockedUseMessages.mockImplementation( + () => + ({ + deleteAllMessages: mockClearAllMessages, + }) as unknown as MessageStore, + ); + mockedUseMessages.mockClear(); }); it("calls onOpenChange with false when the close button (X) is clicked", () => { diff --git a/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx b/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx index cdfe0d22..334ccb21 100644 --- a/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx +++ b/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx @@ -8,7 +8,7 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { useMessageStore } from "@core/stores"; +import { useMessages } from "@core/stores"; import { AlertTriangleIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -22,7 +22,7 @@ export const DeleteMessagesDialog = ({ onOpenChange, }: DeleteMessagesDialogProps) => { const { t } = useTranslation("dialog"); - const { deleteAllMessages } = useMessageStore(); + const { deleteAllMessages } = useMessages(); const handleCloseDialog = () => { onOpenChange(false); }; diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index 19a51e17..f7ba3ec7 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -6,7 +6,7 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { useMessageStore, useNodeDB } from "@core/stores"; +import { useMessages, useNodeDB } from "@core/stores"; import { LockKeyholeOpenIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; @@ -21,7 +21,7 @@ export const RefreshKeysDialog = ({ onOpenChange, }: RefreshKeysDialogProps) => { const { t } = useTranslation("dialog"); - const { activeChat } = useMessageStore(); + const { activeChat } = useMessages(); const { nodeErrors, getNode } = useNodeDB(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts index aa7352cb..40156a95 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,10 +1,10 @@ -import { useDevice, useMessageStore, useNodeDB } from "@core/stores"; +import { useDevice, useMessages, useNodeDB } from "@core/stores"; import { useCallback } from "react"; export function useRefreshKeysDialog() { const { setDialogOpen } = useDevice(); const { removeNode, clearNodeError, getNodeError } = useNodeDB(); - const { activeChat } = useMessageStore(); + const { activeChat } = useMessages(); const handleCloseDialog = useCallback(() => { setDialogOpen("refreshKeys", false); diff --git a/packages/web/src/components/PageComponents/Connect/BLE.tsx b/packages/web/src/components/PageComponents/Connect/BLE.tsx index 9d50b64c..21e32238 100644 --- a/packages/web/src/components/PageComponents/Connect/BLE.tsx +++ b/packages/web/src/components/PageComponents/Connect/BLE.tsx @@ -20,7 +20,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => { const { addDevice } = useDeviceStore(); const { addNodeDB } = useNodeDBStore(); - const messageStore = useMessageStore(); + const { addMessageStore } = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { t } = useTranslation(); @@ -37,6 +37,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => { const transport = await TransportWebBluetooth.createFromDevice(bleDevice); const device = addDevice(id); const nodeDB = addNodeDB(id); + const messageStore = addMessageStore(id); const connection = new MeshDevice(transport, id); connection.configure(); diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx index 8c0733f1..34040002 100644 --- a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx +++ b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx @@ -9,7 +9,9 @@ vi.mock("@core/stores", () => ({ useDeviceStore: vi.fn(() => ({ addDevice: vi.fn(() => ({ addConnection: vi.fn() })), })), - useMessageStore: vi.fn(), + useMessageStore: vi.fn(() => ({ + addMessageStore: vi.fn(), + })), useNodeDBStore: vi.fn(() => ({ addNodeDB: vi.fn(), })), diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.tsx index b4ac3591..ebc24a77 100644 --- a/packages/web/src/components/PageComponents/Connect/HTTP.tsx +++ b/packages/web/src/components/PageComponents/Connect/HTTP.tsx @@ -31,7 +31,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => { const { addDevice } = useDeviceStore(); const { addNodeDB } = useNodeDBStore(); - const messageStore = useMessageStore(); + const { addMessageStore } = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { control, handleSubmit, register } = useForm({ @@ -63,6 +63,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => { const transport = await TransportHTTP.create(data.ip, data.tls); const device = addDevice(id); const nodeDB = addNodeDB(id); + const messageStore = addMessageStore(id); const connection = new MeshDevice(transport, id); connection.configure(); diff --git a/packages/web/src/components/PageComponents/Connect/Serial.tsx b/packages/web/src/components/PageComponents/Connect/Serial.tsx index 4015606a..477ded03 100644 --- a/packages/web/src/components/PageComponents/Connect/Serial.tsx +++ b/packages/web/src/components/PageComponents/Connect/Serial.tsx @@ -20,7 +20,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => { const { addDevice } = useDeviceStore(); const { addNodeDB } = useNodeDBStore(); - const messageStore = useMessageStore(); + const { addMessageStore } = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { t } = useTranslation(); @@ -42,6 +42,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => { const id = randId(); const device = addDevice(id); const nodeDB = addNodeDB(id); + const messageStore = addMessageStore(id); setSelectedDevice(id); const transport = await TransportWebSerial.createFromPort(port); diff --git a/packages/web/src/components/PageComponents/Messages/MessageInput.test.tsx b/packages/web/src/components/PageComponents/Messages/MessageInput.test.tsx index 9f3ca7ab..f22c0130 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageInput.test.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageInput.test.tsx @@ -40,8 +40,11 @@ const mockSetDraft = vi.fn(); const mockGetDraft = vi.fn(); const mockClearDraft = vi.fn(); -vi.mock("@core/stores/messageStore", () => ({ - useMessageStore: vi.fn(() => ({ +vi.mock("@core/stores", () => ({ + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, + }, + useMessages: vi.fn(() => ({ setDraft: mockSetDraft, getDraft: mockGetDraft, clearDraft: mockClearDraft, diff --git a/packages/web/src/components/PageComponents/Messages/MessageInput.tsx b/packages/web/src/components/PageComponents/Messages/MessageInput.tsx index 9afa506b..4a78d068 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageInput.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageInput.tsx @@ -1,6 +1,6 @@ import { Button } from "@components/UI/Button.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 { SendIcon } from "lucide-react"; import { startTransition, useState } from "react"; @@ -13,7 +13,7 @@ export interface MessageInputProps { } export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => { - const { setDraft, getDraft, clearDraft } = useMessageStore(); + const { setDraft, getDraft, clearDraft } = useMessages(); const { t } = useTranslation("messages"); const calculateBytes = (text: string) => new Blob([text]).size; diff --git a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx index 4f75ccef..acf89a63 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx @@ -6,12 +6,7 @@ import { TooltipProvider, TooltipTrigger, } from "@components/UI/Tooltip.tsx"; -import { - MessageState, - useDevice, - useMessageStore, - useNodeDB, -} from "@core/stores"; +import { MessageState, useDevice, useNodeDB } from "@core/stores"; import type { Message } from "@core/stores/messageStore/types.ts"; import { cn } from "@core/utils/cn.ts"; import { type Protobuf, Types } from "@meshtastic/core"; @@ -53,8 +48,7 @@ interface MessageItemProps { export const MessageItem = ({ message }: MessageItemProps) => { const { config } = useDevice(); - const { getNode } = useNodeDB(); - const { getMyNodeNum } = useMessageStore(); + const { getNode, getMyNode } = useNodeDB(); const { t, i18n } = useTranslation("messages"); const MESSAGE_STATUS_MAP = useMemo( @@ -102,7 +96,7 @@ export const MessageItem = ({ message }: MessageItemProps) => { return message.from != null ? getNode(message.from) : null; }, [getNode, message.from]); - const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]); + const myNodeNum = useMemo(() => getMyNode().num, [getMyNode]); const { displayName, shortName, isFavorite } = useMemo(() => { const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0"); diff --git a/packages/web/src/core/stores/utils/useDeviceContext.ts b/packages/web/src/core/hooks/useDeviceContext.ts similarity index 100% rename from packages/web/src/core/stores/utils/useDeviceContext.ts rename to packages/web/src/core/hooks/useDeviceContext.ts diff --git a/packages/web/src/core/hooks/useNewNodeNum.ts b/packages/web/src/core/hooks/useNewNodeNum.ts new file mode 100644 index 00000000..811807cc --- /dev/null +++ b/packages/web/src/core/hooks/useNewNodeNum.ts @@ -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); +} diff --git a/packages/web/src/core/services/dev-overrides.ts b/packages/web/src/core/services/dev-overrides.ts index 98feba69..928c43a5 100644 --- a/packages/web/src/core/services/dev-overrides.ts +++ b/packages/web/src/core/services/dev-overrides.ts @@ -6,5 +6,6 @@ console.log(`Dev mode: ${isDev}`); if (isDev) { featureFlags.setOverrides({ persistNodeDB: true, + persistMessages: true, }); } diff --git a/packages/web/src/core/stores/index.ts b/packages/web/src/core/stores/index.ts index e6c73d19..445e2da5 100644 --- a/packages/web/src/core/stores/index.ts +++ b/packages/web/src/core/stores/index.ts @@ -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 MessageStore, useMessageStore } from "@core/stores/messageStore"; import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; export { CurrentDeviceContext, type DeviceContext, useDeviceContext, -} from "@app/core/stores/utils/useDeviceContext"; +} from "@core/hooks/useDeviceContext"; export { useAppStore } from "@core/stores/appStore"; export { type Device, @@ -19,9 +20,10 @@ export { MessageState, type MessageStore, MessageType, - useMessageStore, // TODO: Bring hook into this file + useMessageStore, } from "@core/stores/messageStore"; export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; +export type { NodeErrorType } from "@core/stores/nodeDBStore/types"; export { SidebarProvider, useSidebar, // TODO: Bring hook into this file @@ -43,3 +45,11 @@ export const useDevice = (): Device => { ); return device; }; +export const useMessages = (): MessageStore => { + const { deviceId } = useDeviceContext(); + + const device = useMessageStore( + (s) => s.getMessageStore(deviceId) ?? s.addMessageStore(deviceId), + ); + return device; +}; diff --git a/packages/web/src/core/stores/messageStore/index.ts b/packages/web/src/core/stores/messageStore/index.ts index a96e1008..32a209cc 100644 --- a/packages/web/src/core/stores/messageStore/index.ts +++ b/packages/web/src/core/stores/messageStore/index.ts @@ -1,4 +1,4 @@ -// import { storageWithMapSupport } from "../storage/indexDB.ts"; +import { featureFlags } from "@core/services/featureFlags"; import type { ChannelId, ClearMessageParams, @@ -10,10 +10,16 @@ import type { NodeNum, SetMessageStateParams, } 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 { persist } from "zustand/middleware"; 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 { Ack = "ack", @@ -33,55 +39,126 @@ export function getConversationId( return [node1, node2].sort((a, b) => a - b).join(":"); } -export interface MessageStore { - messages: { - direct: Map; - broadcast: Map; - }; +export interface MessageBuckets { + direct: Map; + broadcast: Map; } export interface MessageStore { - messages: MessageStore["messages"]; - draft: Map; - nodeNum: number; // This device's node number - activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast + id: number; + myNodeNum: number | undefined; + + messages: MessageBuckets; + drafts: Map; + + // Ephemeral UI state (not persisted) + activeChat: number; chatType: MessageType; setNodeNum: (nodeNum: number) => void; - getMyNodeNum: () => number; saveMessage: (message: Message) => void; setMessageState: (params: SetMessageStateParams) => void; getMessages: (params: GetMessagesParams) => Message[]; + getDraft: (key: Types.Destination) => string; setDraft: (key: Types.Destination, message: string) => void; + clearDraft: (key: Types.Destination) => void; + //clearAllDrafts: (key: Types.Destination) => void; + deleteAllMessages: () => 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; +} + +type MessageStoreData = { + id: number; + myNodeNum: number | undefined; + + messages: MessageBuckets; + drafts: Map; +}; + +type MessageStorePersisted = { + messageStores: Map; +}; + +function messageStoreFactory( + id: number, + get: () => PrivateMessageStoreState, + set: typeof useMessageStore.setState, + data?: Partial, +): MessageStore { + const messages = data?.messages ?? { + direct: new Map(), + broadcast: new Map(), + }; + const drafts = data?.drafts ?? new Map(); + const myNodeNum = data?.myNodeNum; + const activeChat = 0; + const chatType = MessageType.Broadcast; + + return { + id, + myNodeNum, + messages, + drafts, + activeChat, + chatType, -export const useMessageStore = create()( - // persist( - (set, get) => ({ - messages: { - direct: new Map(), - broadcast: new Map(), - }, - draft: new Map(), - activeChat: 0, - chatType: MessageType.Broadcast, - nodeNum: 0, setNodeNum: (nodeNum) => { set( - produce((state: MessageStore) => { - state.nodeNum = nodeNum; + produce((draft) => { + 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) => { set( - produce((state: MessageStore) => { + produce((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) { const conversationId = getConversationId(message.from, message.to); if (!state.messages.direct.has(conversationId)) { @@ -90,9 +167,9 @@ export const useMessageStore = create()( new Map(), ); } - state.messages.direct - .get(conversationId) - ?.set(message.messageId, message); + + log = state.messages.direct.get(conversationId); + log?.set(message.messageId, message); } else if (message.type === MessageType.Broadcast) { const channelId = message.channel as ChannelId; if (!state.messages.broadcast.has(channelId)) { @@ -101,9 +178,14 @@ export const useMessageStore = create()( new Map(), ); } - state.messages.broadcast - .get(channelId) - ?.set(message.messageId, message); + + log = state.messages.broadcast.get(channelId); + log?.set(message.messageId, message); + } + + if (log) { + // Enforce retention limit + evictOldestEntries(log, MESSAGELOG_RETENTION_NUM); } }), ); @@ -111,7 +193,12 @@ export const useMessageStore = create()( setMessageState: (params: SetMessageStateParams) => { set( - produce((state: MessageStore) => { + produce((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + let messageLog: MessageLogMap | undefined; let targetMessage: Message | undefined; @@ -144,8 +231,13 @@ export const useMessageStore = create()( }), ); }, + 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; if (params.type === MessageType.Direct) { @@ -164,9 +256,61 @@ export const useMessageStore = create()( 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((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((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((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + + state.messages.direct = new Map(); + state.messages.broadcast = new Map(); + }), + ); + }, + clearMessageByMessageId: (params: ClearMessageParams) => { set( - produce((state: MessageStore) => { + produce((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + let messageLog: MessageLogMap | undefined; let parentMap: Map; let parentKey: ConversationId | ChannelId; @@ -176,6 +320,7 @@ export const useMessageStore = create()( parentMap = state.messages.direct; messageLog = parentMap.get(parentKey); } else { + // Broadcast parentKey = params.channelId; parentMap = state.messages.broadcast; messageLog = parentMap.get(parentKey); @@ -206,39 +351,109 @@ export const useMessageStore = create()( }), ); }, - getDraft: (key) => { - return get().draft.get(key) ?? ""; - }, - setDraft: (key, message) => { - set( - produce((state: MessageStore) => { - state.draft.set(key, message); - }), - ); - }, - clearDraft: (key) => { - set( - produce((state: MessageStore) => { - state.draft.delete(key); - }), - ); - }, - deleteAllMessages: () => { - set( - produce((state: MessageStore) => { - state.messages.direct = new Map(); - state.messages.broadcast = new Map(); - }), - ); - }, + }; +} + +export const messageStoreInitializer: StateCreator = ( + set, + get, +) => ({ + messageStores: new Map(), + + addMessageStore: (id) => { + const existing = get().messageStores.get(id); + if (existing) { + return existing; + } + + const nodeStore = messageStoreFactory(id, get, set); + set( + produce((draft) => { + draft.messageStores.set(id, nodeStore); + + // Enforce retention limit + evictOldestEntries(draft.messageStores, MESSAGESTORE_RETENTION_NUM); + }), + ); + + return nodeStore; + }, + removeMessageStore: (id) => { + set( + produce((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(), + 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, + }, + ]), + ), }), - // { - // name: 'meshtastic-message-store', - // storage: storageWithMapSupport, - // version: CURRENT_STORE_VERSION, - // partialize: (state) => ({ - // messages: state.messages, - // nodeNum: state.nodeNum, - // }), - // }) + onRehydrateStorage: () => (state) => { + if (!state) { + return; + } + console.debug( + "MessageStoreStore: Rehydrating state with ", + state.messageStores.size, + " MessageStores -", + state.messageStores, + ); + + useMessageStore.setState( + produce((draft) => { + const rebuilt = new Map(); + for (const [id, data] of ( + draft.messageStores as unknown as Map + ).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()(messageStoreInitializer); diff --git a/packages/web/src/core/stores/messageStore/messageStore.test.ts b/packages/web/src/core/stores/messageStore/messageStore.test.ts index 5cc8a9ee..4a36819d 100644 --- a/packages/web/src/core/stores/messageStore/messageStore.test.ts +++ b/packages/web/src/core/stores/messageStore/messageStore.test.ts @@ -1,34 +1,43 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: */ import { Types } from "@meshtastic/core"; +import { setAutoFreeze } from "immer"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - getConversationId, - MessageState, - MessageType, - useMessageStore, -} from "./index.ts"; -import type { - ChannelId, - ConversationId, - Message, - MessageLogMap, -} from "./types.ts"; - -vi.mock("../utils/indexDB.ts", () => { - const memoryStorage: Record = {}; - return { - storageWithMapSupport: { - getItem: vi.fn(async (name: string): Promise => { - return (await memoryStorage[name]) ?? null; - }), - setItem: vi.fn(async (name: string, value: string): Promise => { - memoryStorage[name] = await value; - }), - removeItem: vi.fn(async (name: string): Promise => { - await delete memoryStorage[name]; - }), +import { getConversationId, MessageState, MessageType } from "./index.ts"; +import type { ChannelId, Message } from "./types.ts"; + +const idbMem = new Map(); +vi.mock("idb-keyval", () => ({ + get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))), + set: vi.fn((key: string, val: string) => { + idbMem.set(key, val); + return Promise.resolve(); + }), + del: vi.fn((k: string) => { + idbMem.delete(k); + return Promise.resolve(); + }), +})); + +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 otherNodeNum1 = 222; @@ -90,53 +99,56 @@ const broadcastMessage2: Message = { message: "Broadcast message 2", }; -describe("useMessageStore", () => { - const initialState = useMessageStore.getState(); - +describe("MessageStore persistence & rehydrate", () => { beforeEach(() => { - useMessageStore.setState( - { - ...initialState, - messages: { - direct: new Map(), - broadcast: new Map(), - }, - draft: new Map(), - }, - true, - ); + idbMem.clear(); + vi.clearAllMocks(); }); - it("should have correct initial state", () => { + it("should have correct initial state", async () => { + const { useMessageStore } = await freshStore(); const state = useMessageStore.getState(); - expect(state.messages.direct).toBeInstanceOf(Map); - 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); + const store = state.addMessageStore(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); + 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", () => { - useMessageStore.getState().setNodeNum(myNodeNum); - expect(useMessageStore.getState().nodeNum).toBe(myNodeNum); + 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, + ); }); - describe("saveMessage", () => { + describe("saveMessage", async () => { + const { useMessageStore } = await freshStore(); + const state = useMessageStore.getState(); + state.addMessageStore(123); + it("should save a direct message with correct Map structure", () => { - useMessageStore.getState().saveMessage(directMessageToOther1); - const state = useMessageStore.getState(); + state.getMessageStore(123)?.saveMessage(directMessageToOther1); const conversationId = getConversationId( directMessageToOther1.from, directMessageToOther1.to, ); + const store = state.getMessageStore(123)!; + // Check if the conversation Map exists - expect(state.messages.direct.has(conversationId)).toBe(true); - const conversationLog = state.messages.direct.get(conversationId); + expect(store.messages.direct.has(conversationId)).toBe(true); + const conversationLog = store.messages.direct.get(conversationId); // Check if the inner Map (MessageLogMap) exists and is a Map expect(conversationLog).toBeInstanceOf(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", () => { - useMessageStore.getState().saveMessage(broadcastMessage1); - const state = useMessageStore.getState(); + state.getMessageStore(123)?.saveMessage(broadcastMessage1); + const store = state.getMessageStore(123)!; const channelId = broadcastMessage1.channel; - expect(state.messages.broadcast.has(channelId)).toBe(true); - const channelLog = state.messages.broadcast.get(channelId); + expect(store.messages.broadcast.has(channelId)).toBe(true); + const channelLog = store.messages.broadcast.get(channelId); expect(channelLog).toBeInstanceOf(Map); expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true); expect(channelLog?.get(broadcastMessage1.messageId)).toEqual( @@ -162,46 +174,50 @@ describe("useMessageStore", () => { }); it("should save multiple messages correctly", () => { - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(directMessageFromOther1); - useMessageStore.getState().saveMessage(broadcastMessage1); + state.getMessageStore(123)?.saveMessage(directMessageToOther1); + state.getMessageStore(123)?.saveMessage(directMessageFromOther1); + state.getMessageStore(123)?.saveMessage(broadcastMessage1); - const state = useMessageStore.getState(); + const store = state.getMessageStore(123)!; const convId1 = getConversationId(myNodeNum, otherNodeNum1); expect( - state.messages.direct + store.messages.direct .get(convId1) ?.get(directMessageToOther1.messageId), ).toEqual(directMessageToOther1); expect( - state.messages.direct + store.messages.direct .get(convId1) ?.get(directMessageFromOther1.messageId), ).toEqual(directMessageFromOther1); const channelId = broadcastMessage1.channel; expect( - state.messages.broadcast + store.messages.broadcast .get(channelId) ?.get(broadcastMessage1.messageId), ).toEqual(broadcastMessage1); }); }); - describe("getMessages", () => { + describe("getMessages", async () => { + const { useMessageStore } = await freshStore(); + const state = useMessageStore.getState(); + state.addMessageStore(123); + beforeEach(() => { - useMessageStore.getState().setNodeNum(myNodeNum); - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(directMessageFromOther1); - useMessageStore.getState().saveMessage(directMessageToOther2); - useMessageStore.getState().saveMessage(broadcastMessage1); - useMessageStore.getState().saveMessage(broadcastMessage2); + state.getMessageStore(123)?.setNodeNum(myNodeNum); + state.getMessageStore(123)?.saveMessage(directMessageToOther1); + state.getMessageStore(123)?.saveMessage(directMessageFromOther1); + state.getMessageStore(123)?.saveMessage(directMessageToOther2); + state.getMessageStore(123)?.saveMessage(broadcastMessage1); + state.getMessageStore(123)?.saveMessage(broadcastMessage2); }); 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, channelId: broadcastChannel, }); @@ -211,7 +227,7 @@ describe("useMessageStore", () => { }); 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, channelId: Types.ChannelNumber.Channel1, }); @@ -219,7 +235,7 @@ describe("useMessageStore", () => { }); 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, nodeA: myNodeNum, nodeB: otherNodeNum1, @@ -230,7 +246,7 @@ describe("useMessageStore", () => { }); 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, nodeA: myNodeNum, nodeB: otherNodeNum2, @@ -240,7 +256,7 @@ describe("useMessageStore", () => { }); 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, nodeA: myNodeNum, 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(() => { - useMessageStore.getState().setNodeNum(myNodeNum); - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(directMessageFromOther1); - useMessageStore.getState().saveMessage(broadcastMessage1); + state.getMessageStore(123)?.setNodeNum(myNodeNum); + state.getMessageStore(123)?.saveMessage(directMessageToOther1); + state.getMessageStore(123)?.saveMessage(directMessageFromOther1); + state.getMessageStore(123)?.saveMessage(broadcastMessage1); }); it("should update state for a direct message", () => { - useMessageStore.getState().setMessageState({ + state.getMessageStore(123)!.setMessageState({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, @@ -271,13 +291,14 @@ describe("useMessageStore", () => { ); const message = useMessageStore .getState() + .getMessageStore(123)! .messages.direct.get(conversationId) ?.get(directMessageToOther1.messageId); expect(message?.state).toBe(MessageState.Ack); }); it("should update state for another direct message in the same conversation", () => { - useMessageStore.getState().setMessageState({ + state.getMessageStore(123)!.setMessageState({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, @@ -290,13 +311,14 @@ describe("useMessageStore", () => { ); const message = useMessageStore .getState() + .getMessageStore(123)! .messages.direct.get(conversationId) ?.get(directMessageFromOther1.messageId); expect(message?.state).toBe(MessageState.Failed); }); it("should update state for a broadcast message", () => { - useMessageStore.getState().setMessageState({ + state.getMessageStore(123)!.setMessageState({ type: MessageType.Broadcast, channelId: broadcastChannel, messageId: broadcastMessage1.messageId, @@ -304,6 +326,7 @@ describe("useMessageStore", () => { }); const message = useMessageStore .getState() + .getMessageStore(123)! .messages.broadcast.get(broadcastChannel) ?.get(broadcastMessage1.messageId); expect(message?.state).toBe(MessageState.Ack); @@ -311,7 +334,7 @@ describe("useMessageStore", () => { it("should warn if message is not found (direct)", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - useMessageStore.getState().setMessageState({ + state.getMessageStore(123)!.setMessageState({ type: MessageType.Direct, nodeA: myNodeNum, nodeB: otherNodeNum1, @@ -328,7 +351,7 @@ describe("useMessageStore", () => { it("should warn if message is not found (broadcast)", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - useMessageStore.getState().setMessageState({ + state.getMessageStore(123)!.setMessageState({ type: MessageType.Broadcast, channelId: broadcastChannel, messageId: 999, @@ -344,7 +367,7 @@ describe("useMessageStore", () => { it("should warn if conversation/channel is not found", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - useMessageStore.getState().setMessageState({ + state.getMessageStore(123)!.setMessageState({ type: MessageType.Direct, nodeA: myNodeNum, 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; beforeEach(() => { - useMessageStore.getState().setNodeNum(myNodeNum); - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(directMessageFromOther1); - useMessageStore.getState().saveMessage(broadcastMessage1); - useMessageStore.getState().saveMessage({ + state.getMessageStore(123)?.setNodeNum(myNodeNum); + state.getMessageStore(123)?.saveMessage(directMessageToOther1); + state.getMessageStore(123)?.saveMessage(directMessageFromOther1); + state.getMessageStore(123)?.saveMessage(broadcastMessage1); + state.getMessageStore(123)?.saveMessage({ ...directMessageToOther1, messageId: extraDirectMessageId, date: Date.now() + 50, @@ -380,21 +407,21 @@ describe("useMessageStore", () => { const nodeB = directMessageToOther1.to; const conversationId = getConversationId(nodeA, nodeB); - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Direct, nodeA: nodeA, nodeB: nodeB, messageId: messageIdToDelete, }); - const state = useMessageStore.getState(); - const conversationLog = state.messages.direct.get(conversationId); + const store = useMessageStore.getState().getMessageStore(123)!; + const conversationLog = store.messages.direct.get(conversationId); expect(conversationLog?.has(messageIdToDelete)).toBe(false); expect(conversationLog?.has(extraDirectMessageId)).toBe(true); expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe( true, ); - expect(state.messages.direct.has(conversationId)).toBe(true); + expect(store.messages.direct.has(conversationId)).toBe(true); }); it("should delete another specific direct message", () => { @@ -403,15 +430,15 @@ describe("useMessageStore", () => { const nodeB = directMessageFromOther1.to; const conversationId = getConversationId(nodeA, nodeB); - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Direct, nodeA: nodeA, nodeB: nodeB, messageId: messageIdToDelete, }); - const state = useMessageStore.getState(); - const conversationLog = state.messages.direct.get(conversationId); + const store = useMessageStore.getState().getMessageStore(123)!; + const conversationLog = store.messages.direct.get(conversationId); expect(conversationLog?.has(messageIdToDelete)).toBe(false); expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true); expect(conversationLog?.has(extraDirectMessageId)).toBe(true); @@ -421,15 +448,15 @@ describe("useMessageStore", () => { const messageIdToDelete = broadcastMessage1.messageId; const channelId = broadcastMessage1.channel; - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Broadcast, channelId: channelId, messageId: messageIdToDelete, }); - const state = useMessageStore.getState(); + const store = useMessageStore.getState().getMessageStore(123)!; expect( - state.messages.broadcast.get(channelId)?.get(messageIdToDelete), + store.messages.broadcast.get(channelId)?.get(messageIdToDelete), ).toBeUndefined(); }); @@ -440,37 +467,37 @@ describe("useMessageStore", () => { ); const broadcastChanId = broadcastMessage1.channel; - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: directMessageToOther1.messageId, }); - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, messageId: directMessageFromOther1.messageId, }); - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: extraDirectMessageId, }); - expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe( - false, - ); + expect( + state.getMessageStore(123)?.messages.direct.has(directConvId), + ).toBe(false); - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId, }); expect( - useMessageStore.getState().messages.broadcast.has(broadcastChanId), + state.getMessageStore(123)?.messages.broadcast.has(broadcastChanId), ).toBe(false); }); @@ -479,7 +506,7 @@ describe("useMessageStore", () => { const conversationId = getConversationId(myNodeNum, otherNodeNum1); expect(() => { - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Direct, nodeA: myNodeNum, nodeB: otherNodeNum1, @@ -487,8 +514,8 @@ describe("useMessageStore", () => { }); }).not.toThrow(); - const state = useMessageStore.getState(); - const conversationLog = state.messages.direct.get(conversationId); + const store = useMessageStore.getState().getMessageStore(123)!; + const conversationLog = store.messages.direct.get(conversationId); expect(conversationLog?.size).toBe(3); // 101, 102, 1011 expect(warnSpy).toHaveBeenCalledWith( 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", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); expect(() => { - useMessageStore.getState().clearMessageByMessageId({ + state.getMessageStore(123)?.clearMessageByMessageId({ type: MessageType.Direct, nodeA: myNodeNum, 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 draftKeyBroadcast = broadcastChannel; const draftMessage = "This is a draft"; it("should set and get a draft for direct chat", () => { - useMessageStore.getState().setDraft(draftKeyDirect, draftMessage); - expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe( + state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage); + expect(state.getMessageStore(123)?.drafts.get(draftKeyDirect)).toBe( draftMessage, ); - expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe( + expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe( draftMessage, ); }); it("should set and get a draft for broadcast chat", () => { - useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage); - expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe( + state.getMessageStore(123)?.setDraft(draftKeyBroadcast, draftMessage); + expect(state.getMessageStore(123)?.drafts.get(draftKeyBroadcast)).toBe( draftMessage, ); - expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe( + expect(state.getMessageStore(123)?.getDraft(draftKeyBroadcast)).toBe( draftMessage, ); }); 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", () => { - useMessageStore.getState().setDraft(draftKeyDirect, draftMessage); - expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true); - useMessageStore.getState().clearDraft(draftKeyDirect); - expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false); - expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(""); + state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage); + expect(state.getMessageStore(123)?.drafts.has(draftKeyDirect)).toBe(true); + state.getMessageStore(123)?.clearDraft(draftKeyDirect); + expect(state.getMessageStore(123)?.drafts.has(draftKeyDirect)).toBe( + false, + ); + expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe(""); }); }); - describe("deleteAllMessages", () => { + describe("deleteAllMessages", async () => { + const { useMessageStore } = await freshStore(); + const state = useMessageStore.getState(); + state.addMessageStore(123); + it("should clear all direct and broadcast messages, leaving empty Maps", () => { - useMessageStore.getState().saveMessage(directMessageToOther1); - useMessageStore.getState().saveMessage(broadcastMessage1); + state.getMessageStore(123)?.saveMessage(directMessageToOther1); + state.getMessageStore(123)?.saveMessage(broadcastMessage1); - expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan( + expect(state.getMessageStore(123)?.messages.direct.size).toBeGreaterThan( 0, ); expect( - useMessageStore.getState().messages.broadcast.size, + state.getMessageStore(123)?.messages.broadcast.size, ).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(); - expect(state.messages.direct).toBeInstanceOf(Map); - expect(state.messages.direct.size).toBe(0); - expect(state.messages.broadcast).toBeInstanceOf(Map); - expect(state.messages.broadcast.size).toBe(0); + + for (let i = 1; i <= 10; i++) { + state.addMessageStore(i); + } + // 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 + } }); }); }); diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts index ccda39f0..9e4e8dc3 100644 --- a/packages/web/src/core/stores/nodeDBStore/index.ts +++ b/packages/web/src/core/stores/nodeDBStore/index.ts @@ -1,5 +1,7 @@ import { create } from "@bufbuild/protobuf"; 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 { Protobuf, type Types } from "@meshtastic/core"; import { produce } from "immer"; @@ -33,6 +35,7 @@ export interface NodeDB { getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; getNodes: ( filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, + includeSelf?: boolean, ) => Protobuf.Mesh.NodeInfo[]; getMyNode: () => Protobuf.Mesh.NodeInfo; @@ -85,7 +88,21 @@ function nodeDBFactory( if (!nodeDB) { 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); 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; nodeDB.nodeMap.set(data.from, node); } else { @@ -173,7 +190,7 @@ function nodeDBFactory( data.from, create(Protobuf.Mesh.NodeInfoSchema, { 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, }), ); @@ -225,21 +242,46 @@ function nodeDBFactory( for (const [key, oldDB] of draft.nodeDBs) { if (key === id) { + // short-circuit self continue; } if (oldDB.myNodeNum === nodeNum) { - // The new DB is typically empty when nodenum is set, so we can safely copy over from the old DB - // otherwise, discard the old DB completely - if (newDB.nodeMap.size === 0) { - newDB.nodeMap = oldDB.nodeMap; - newDB.nodeErrors = oldDB.nodeErrors; - } else { - console.error( - `NodeDB with id: ${id} already has nodes, not merging with old DB`, + // We found the oldDB (same myNodeNum). Merge node-by-node as if the new nodes are added with addNode + + const mergedNodes = new Map(oldDB.nodeMap); + const mergedErrors = new Map(oldDB.nodeErrors); + + const getNodesProxy = ( + filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, + ): 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); }, - getNodes: (filter) => { + getNodes: (filter, includeSelf) => { const nodeDB = get().nodeDBs.get(id); if (!nodeDB) { throw new Error(`No nodeDB found (id: ${id})`); } - const all = Array.from(nodeDB.nodeMap.values()).filter( - (n) => n.num !== nodeDB.myNodeNum, + const all = Array.from(nodeDB.nodeMap.values()).filter((n) => + includeSelf ? true : n.num !== nodeDB.myNodeNum, ); + return filter ? all.filter(filter) : all; }, @@ -351,13 +394,8 @@ export const nodeDBInitializer: StateCreator = ( produce((draft) => { draft.nodeDBs.set(id, nodeDB); - // If over limit, remove oldest inserted. FIFO - if (draft.nodeDBs.size > NODEDB_RETENTION_NUM) { - const firstKey = draft.nodeDBs.keys().next().value; - if (firstKey !== undefined) { - draft.nodeDBs.delete(firstKey); - } - } + // Enforce retention limit + evictOldestEntries(draft.nodeDBs, NODEDB_RETENTION_NUM); }), ); diff --git a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts index 6acf7c18..da2b4192 100644 --- a/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts +++ b/packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts @@ -1,3 +1,8 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: */ +/** biome-ignore-all lint/style/noNonNullAssertion: */ +import { create } from "@bufbuild/protobuf"; +import { Protobuf } from "@meshtastic/core"; +import { toByteArray } from "base64-js"; import { beforeEach, describe, expect, it, vi } from "vitest"; const idbMem = new Map(); @@ -14,27 +19,32 @@ vi.mock("idb-keyval", () => ({ })); // 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(); - const mod = await import("./index.ts"); - return mod; -} -vi.mock("@core/services/featureFlags", () => { - return { + // 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) => { - if (key === "persistNodeDB") { - return true; - } - return false; - }), + get: vi.fn((key: string) => (key === "persistNodeDB" ? persist : false)), }, - }; -}); + })); + + const mod = await import("./index.ts"); + return mod; +} function makeNode(num: number, extras: Record = {}) { - return { num, ...extras } as any; + return create(Protobuf.Mesh.NodeInfoSchema, { num, ...extras }); +} +function makeUser(fields: Record) { + return create(Protobuf.Mesh.UserSchema, fields); +} +function makePosition(fields: Record) { + return create(Protobuf.Mesh.PositionSchema, fields); } describe("NodeDB store", () => { @@ -85,6 +95,10 @@ describe("NodeDB store", () => { db.processPacket({ from: 50, time: 2222, snr: 9 } as any); expect(db.getNode(50)?.lastHeard).toBe(2222); 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 () => { @@ -145,27 +159,9 @@ describe("NodeDB store", () => { 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 () => { { - const { useNodeDBStore } = await freshStore(); + const { useNodeDBStore } = await freshStore(true); // with persistence const st = useNodeDBStore.getState(); const db = st.addNodeDB(123); db.setNodeNum(321); @@ -173,7 +169,7 @@ describe("NodeDB store", () => { db.setNodeError(50, "ERROR" as any); } { - const { useNodeDBStore } = await freshStore(); + const { useNodeDBStore } = await freshStore(true); // with persistence const st = useNodeDBStore.getState(); const db = st.getNodeDB(123)!; @@ -213,7 +209,7 @@ describe("NodeDB store", () => { it("removeNodeDB persists removal across reload", async () => { { - const { useNodeDBStore } = await freshStore(); + const { useNodeDBStore } = await freshStore(true); // with persistence const st = useNodeDBStore.getState(); st.addNodeDB(99); expect(st.getNodeDB(99)).toBeDefined(); @@ -221,7 +217,7 @@ describe("NodeDB store", () => { expect(st.getNodeDB(99)).toBeUndefined(); } { - const { useNodeDBStore } = await freshStore(); + const { useNodeDBStore } = await freshStore(true); // with persistence const st = useNodeDBStore.getState(); 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 () => { { - const { useNodeDBStore } = await freshStore(); + const { useNodeDBStore } = await freshStore(true); // with persistence const st = useNodeDBStore.getState(); const orphan = st.addNodeDB(500); // no setNodeNum @@ -240,7 +236,7 @@ describe("NodeDB store", () => { good.addNode(makeNode(2)); } { - const { useNodeDBStore } = await freshStore(); + const { useNodeDBStore } = await freshStore(true); // with persistence const st = useNodeDBStore.getState(); expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped expect(st.getNodeDB(501)).toBeDefined(); // kept @@ -259,3 +255,199 @@ describe("NodeDB store", () => { 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); + }); +}); diff --git a/packages/web/src/core/stores/nodeDBStore/nodeValidation.ts b/packages/web/src/core/stores/nodeDBStore/nodeValidation.ts new file mode 100644 index 00000000..52975660 --- /dev/null +++ b/packages/web/src/core/stores/nodeDBStore/nodeValidation.ts @@ -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 + } +} diff --git a/packages/web/src/core/stores/nodeDBStore/types.ts b/packages/web/src/core/stores/nodeDBStore/types.ts index 105ddbf0..82523ec2 100644 --- a/packages/web/src/core/stores/nodeDBStore/types.ts +++ b/packages/web/src/core/stores/nodeDBStore/types.ts @@ -1,6 +1,9 @@ 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 = { node: number; diff --git a/packages/web/src/core/stores/utils/evictOldestEntries.ts b/packages/web/src/core/stores/utils/evictOldestEntries.ts new file mode 100644 index 00000000..500726de --- /dev/null +++ b/packages/web/src/core/stores/utils/evictOldestEntries.ts @@ -0,0 +1,14 @@ +export function evictOldestEntries( + map: Map, + 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 + } + } +} diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 4c1791f0..4c4dd4b7 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -1,5 +1,6 @@ import { ensureDefaultUser } from "@core/dto/NodeNumToNodeInfoDTO.ts"; import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts"; +import { useNewNodeNum } from "@core/hooks/useNewNodeNum"; import { type Device, type MessageStore, @@ -7,8 +8,6 @@ import { type NodeDB, } from "@core/stores"; import { type MeshDevice, Protobuf } from "@meshtastic/core"; -import { fromByteArray } from "base64-js"; - export const subscribeAll = ( device: Device, connection: MeshDevice, @@ -57,9 +56,7 @@ export const subscribeAll = ( }); connection.events.onMyNodeInfo.subscribe((nodeInfo) => { - device.setHardware(nodeInfo); - messageStore.setNodeNum(nodeInfo.myNodeNum); - nodeDB.setNodeNum(nodeInfo.myNodeNum); + useNewNodeNum(device.id, nodeInfo); myNodeNum = nodeInfo.myNodeNum; }); @@ -74,24 +71,7 @@ export const subscribeAll = ( connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { const nodeWithUser = ensureDefaultUser(nodeInfo); - if (nodeWithUser.num !== myNodeNum && nodeDB.getNode(nodeWithUser.num)) { - 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?) - } - } - + // PKI sanity check is handled inside nodeDB.addNode nodeDB.addNode(nodeWithUser); }); diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx index 37d9ed1d..59370612 100644 --- a/packages/web/src/pages/Messages.tsx +++ b/packages/web/src/pages/Messages.tsx @@ -12,7 +12,7 @@ import { MessageState, MessageType, useDevice, - useMessageStore, + useMessages, useNodeDB, useSidebar, } from "@core/stores"; @@ -44,9 +44,9 @@ function SelectMessageChat() { export const MessagesPage = () => { 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 }); @@ -77,7 +77,10 @@ export const MessagesPage = () => { useEffect(() => { if (!type && !chatId && filteredChannels.length > 0) { const defaultChannel = filteredChannels[0]; - navigateToChat(MessageType.Broadcast, defaultChannel.index.toString()); + navigateToChat( + MessageType.Broadcast, + defaultChannel?.index.toString() ?? "0", + ); } }, [type, chatId, filteredChannels, navigateToChat]); @@ -138,7 +141,7 @@ export const MessagesPage = () => { } else { setMessageState({ type: MessageType.Direct, - nodeA: getMyNodeNum(), + nodeA: getMyNode().num, nodeB: numericChatId, messageId, newState: MessageState.Ack, @@ -160,7 +163,7 @@ export const MessagesPage = () => { } else { setMessageState({ type: MessageType.Direct, - nodeA: getMyNodeNum(), + nodeA: getMyNode().num, nodeB: numericChatId, messageId: failedId, newState: MessageState.Failed, @@ -168,14 +171,7 @@ export const MessagesPage = () => { } } }, - [ - numericChatId, - chatType, - connection, - getMyNodeNum, - setMessageState, - isDirect, - ], + [numericChatId, chatType, connection, getMyNode, setMessageState, isDirect], ); const renderChatContent = () => { @@ -194,7 +190,7 @@ export const MessagesPage = () => {