Browse Source

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- <[email protected]>
Co-authored-by: Dan Ditomaso <[email protected]>
pull/834/head
Jeremy Gallant 9 months ago
committed by GitHub
parent
commit
dcb44d27fe
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 20
      packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx
  2. 4
      packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  3. 4
      packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  4. 4
      packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  5. 3
      packages/web/src/components/PageComponents/Connect/BLE.tsx
  6. 4
      packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
  7. 3
      packages/web/src/components/PageComponents/Connect/HTTP.tsx
  8. 3
      packages/web/src/components/PageComponents/Connect/Serial.tsx
  9. 7
      packages/web/src/components/PageComponents/Messages/MessageInput.test.tsx
  10. 4
      packages/web/src/components/PageComponents/Messages/MessageInput.tsx
  11. 12
      packages/web/src/components/PageComponents/Messages/MessageItem.tsx
  12. 0
      packages/web/src/core/hooks/useDeviceContext.ts
  13. 14
      packages/web/src/core/hooks/useNewNodeNum.ts
  14. 1
      packages/web/src/core/services/dev-overrides.ts
  15. 16
      packages/web/src/core/stores/index.ts
  16. 361
      packages/web/src/core/stores/messageStore/index.ts
  17. 495
      packages/web/src/core/stores/messageStore/messageStore.test.ts
  18. 82
      packages/web/src/core/stores/nodeDBStore/index.ts
  19. 270
      packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts
  20. 63
      packages/web/src/core/stores/nodeDBStore/nodeValidation.ts
  21. 5
      packages/web/src/core/stores/nodeDBStore/types.ts
  22. 14
      packages/web/src/core/stores/utils/evictOldestEntries.ts
  23. 26
      packages/web/src/core/subscriptions.ts
  24. 26
      packages/web/src/pages/Messages.tsx

20
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", () => {

4
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);
};

4
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();

4
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);

3
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();

4
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(),
})),

3
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<FormData>({
@ -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();

3
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);

7
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,

4
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;

12
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");

0
packages/web/src/core/stores/utils/useDeviceContext.ts → packages/web/src/core/hooks/useDeviceContext.ts

14
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);
}

1
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,
});
}

16
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;
};

361
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<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
export interface MessageBuckets {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
}
export interface MessageStore {
messages: MessageStore["messages"];
draft: Map<Types.Destination, string>;
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<Types.Destination, string>;
// 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<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) => {
set(
produce((state: MessageStore) => {
state.nodeNum = nodeNum;
produce<PrivateMessageStoreState>((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<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) {
const conversationId = getConversationId(message.from, message.to);
if (!state.messages.direct.has(conversationId)) {
@ -90,9 +167,9 @@ export const useMessageStore = create<MessageStore>()(
new Map<MessageId, Message>(),
);
}
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<MessageStore>()(
new Map<MessageId, Message>(),
);
}
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<MessageStore>()(
setMessageState: (params: SetMessageStateParams) => {
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 targetMessage: Message | undefined;
@ -144,8 +231,13 @@ export const useMessageStore = create<MessageStore>()(
}),
);
},
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<MessageStore>()(
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) => {
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 parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
let parentKey: ConversationId | ChannelId;
@ -176,6 +320,7 @@ export const useMessageStore = create<MessageStore>()(
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<MessageStore>()(
}),
);
},
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<ConversationId, MessageLogMap>();
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
}),
);
},
};
}
export const messageStoreInitializer: StateCreator<PrivateMessageStoreState> = (
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<PrivateMessageStoreState>((draft) => {
draft.messageStores.set(id, nodeStore);
// Enforce retention limit
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,
},
]),
),
}),
// {
// 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<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);

495
packages/web/src/core/stores/messageStore/messageStore.test.ts

@ -1,34 +1,43 @@
/** biome-ignore-all lint/style/noNonNullAssertion: <tests> */
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<string, string> = {};
return {
storageWithMapSupport: {
getItem: vi.fn(async (name: string): Promise<string | null> => {
return (await memoryStorage[name]) ?? null;
}),
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
memoryStorage[name] = await value;
}),
removeItem: vi.fn(async (name: string): Promise<void> => {
await delete memoryStorage[name];
}),
import { getConversationId, MessageState, MessageType } from "./index.ts";
import type { ChannelId, Message } from "./types.ts";
const idbMem = new Map<string, string>();
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<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
},
draft: new Map<Types.Destination, string>(),
},
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
}
});
});
});

82
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<PrivateNodeDBState> = (
produce<PrivateNodeDBState>((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);
}),
);

270
packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.ts

@ -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";
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)
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<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", () => {
@ -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);
});
});

63
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
}
}

5
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;

14
packages/web/src/core/stores/utils/evictOldestEntries.ts

@ -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
}
}
}

26
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);
});

26
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 = () => {
<ChannelChat
messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeA: getMyNode().num,
nodeB: numericChatId,
}).reverse()}
/>

Loading…
Cancel
Save