Persistent message store (#814)
* Fix default filter behaviour * Persist message store * messageStore tests, node PKI validation Implement node validation and improve merging logic - Added `validateIncomingNode` function to validate new nodes against existing nodes, checking for public key conflicts and ensuring proper handling of node updates. - Updated `nodeDBFactory` to utilize the new validation function when adding nodes. - Enhanced `getNodes` method to optionally include the current node in the results. - Removed the `mergeNodeInfo` utility as its functionality is now integrated into the validation and merging process. - Updated tests to cover new validation logic and ensure correct behavior during node addition and merging. - Cleaned up unused utility functions related to key comparison. * refactor: reuse eviction logic for message and node stores * Update format, move hooks * Improve test performance * Update imports --------- Co-authored-by: philon- <philon-@users.noreply.github.com> Co-authored-by: Dan Ditomaso <dan.ditomaso@gmail.com>
This commit is contained in:
co-authored by
philon-
Dan Ditomaso
parent
799283fd46
commit
dcb44d27fe
+12
-6
@@ -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(() => ({
|
||||
const mockedUseMessages = vi.mocked(useMessages);
|
||||
mockedUseMessages.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
deleteAllMessages: mockClearAllMessages,
|
||||
}));
|
||||
mockedUseMessageStore.mockClear();
|
||||
}) as unknown as MessageStore,
|
||||
);
|
||||
mockedUseMessages.mockClear();
|
||||
});
|
||||
|
||||
it("calls onOpenChange with false when the close button (X) is clicked", () => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,0 +1,14 @@
|
||||
import { useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
|
||||
export function useNewNodeNum(
|
||||
id: number,
|
||||
nodeInfo: Protobuf.Mesh.MyNodeInfo,
|
||||
): void {
|
||||
useDeviceStore.getState().getDevice(id)?.setHardware(nodeInfo);
|
||||
useNodeDBStore.getState().getNodeDB(id)?.setNodeNum(nodeInfo.myNodeNum);
|
||||
useMessageStore
|
||||
.getState()
|
||||
.getMessageStore(id)
|
||||
?.setNodeNum(nodeInfo.myNodeNum);
|
||||
}
|
||||
@@ -6,5 +6,6 @@ console.log(`Dev mode: ${isDev}`);
|
||||
if (isDev) {
|
||||
featureFlags.setOverrides({
|
||||
persistNodeDB: true,
|
||||
persistMessages: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useDeviceContext } from "@app/core/stores/utils/useDeviceContext";
|
||||
import { useDeviceContext } from "@core/hooks/useDeviceContext";
|
||||
import { type Device, useDeviceStore } from "@core/stores/deviceStore";
|
||||
import { type 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;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
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>;
|
||||
}
|
||||
|
||||
export const useMessageStore = create<MessageStore>()(
|
||||
// persist(
|
||||
(set, get) => ({
|
||||
messages: {
|
||||
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>(),
|
||||
},
|
||||
draft: new Map<number, string>(),
|
||||
activeChat: 0,
|
||||
chatType: MessageType.Broadcast,
|
||||
nodeNum: 0,
|
||||
};
|
||||
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,
|
||||
|
||||
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) => {
|
||||
};
|
||||
}
|
||||
|
||||
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((state: MessageStore) => {
|
||||
state.draft.set(key, message);
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
draft.messageStores.set(id, nodeStore);
|
||||
|
||||
// Enforce retention limit
|
||||
evictOldestEntries(draft.messageStores, MESSAGESTORE_RETENTION_NUM);
|
||||
}),
|
||||
);
|
||||
|
||||
return nodeStore;
|
||||
},
|
||||
clearDraft: (key) => {
|
||||
removeMessageStore: (id) => {
|
||||
set(
|
||||
produce((state: MessageStore) => {
|
||||
state.draft.delete(key);
|
||||
produce<PrivateMessageStoreState>((draft) => {
|
||||
draft.messageStores.delete(id);
|
||||
}),
|
||||
);
|
||||
},
|
||||
deleteAllMessages: () => {
|
||||
set(
|
||||
produce((state: MessageStore) => {
|
||||
state.messages.direct = new Map<ConversationId, MessageLogMap>();
|
||||
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
|
||||
getMessageStores: () => Array.from(get().messageStores.values()),
|
||||
getMessageStore: (id) => get().messageStores.get(id),
|
||||
});
|
||||
|
||||
const persistOptions: PersistOptions<
|
||||
PrivateMessageStoreState,
|
||||
MessageStorePersisted
|
||||
> = {
|
||||
name: "meshtastic-MessageStore-store",
|
||||
storage: createStorage<MessageStorePersisted>(),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (s): MessageStorePersisted => ({
|
||||
messageStores: new Map(
|
||||
Array.from(s.messageStores.entries()).map(([id, db]) => [
|
||||
id,
|
||||
{
|
||||
id: db.id,
|
||||
myNodeNum: db.myNodeNum,
|
||||
messages: db.messages,
|
||||
drafts: db.drafts,
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
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;
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
// {
|
||||
// name: 'meshtastic-message-store',
|
||||
// storage: storageWithMapSupport,
|
||||
// version: CURRENT_STORE_VERSION,
|
||||
// partialize: (state) => ({
|
||||
// messages: state.messages,
|
||||
// nodeNum: state.nodeNum,
|
||||
// }),
|
||||
// })
|
||||
};
|
||||
|
||||
// Add persist middleware on the store if the feature flag is enabled
|
||||
const persistMessages = featureFlags.get("persistMessages");
|
||||
console.debug(
|
||||
`MessageStore: Persisting messages is ${persistMessages ? "enabled" : "disabled"}`,
|
||||
);
|
||||
|
||||
export const useMessageStore = persistMessages
|
||||
? createStore<
|
||||
PrivateMessageStoreState,
|
||||
[["zustand/persist", MessageStorePersisted]]
|
||||
>(persist(messageStoreInitializer, persistOptions))
|
||||
: createStore<PrivateMessageStoreState>()(messageStoreInitializer);
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
/** biome-ignore-all lint/style/noNonNullAssertion: <tests> */
|
||||
import { Types } from "@meshtastic/core";
|
||||
import { 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";
|
||||
import { getConversationId, MessageState, MessageType } from "./index.ts";
|
||||
import type { ChannelId, Message } 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;
|
||||
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();
|
||||
}),
|
||||
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];
|
||||
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", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
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", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
const db = state.addMessageStore(123);
|
||||
|
||||
db.setNodeNum(myNodeNum);
|
||||
expect(useMessageStore.getState().getMessageStore(123)?.myNodeNum).toBe(
|
||||
myNodeNum,
|
||||
);
|
||||
});
|
||||
|
||||
it("should have correct initial state", () => {
|
||||
describe("saveMessage", async () => {
|
||||
const { useMessageStore } = await freshStore();
|
||||
const state = useMessageStore.getState();
|
||||
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);
|
||||
});
|
||||
state.addMessageStore(123);
|
||||
|
||||
it("should set nodeNum", () => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
|
||||
});
|
||||
|
||||
describe("saveMessage", () => {
|
||||
it("should save a direct message with correct Map structure", () => {
|
||||
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();
|
||||
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);
|
||||
|
||||
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();
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
draft.nodeDBs.delete(key);
|
||||
const err = newDB.getNodeError(nodeNum);
|
||||
if (err && !oldDB.hasNodeError(nodeNum)) {
|
||||
mergedErrors.set(nodeNum, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
// suppress console output from the store during tests (for github actions)
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
|
||||
vi.doMock("@core/services/featureFlags", () => ({
|
||||
featureFlags: {
|
||||
get: vi.fn((key: string) => (key === "persistNodeDB" ? persist : false)),
|
||||
},
|
||||
}));
|
||||
|
||||
const mod = await import("./index.ts");
|
||||
return mod;
|
||||
}
|
||||
|
||||
vi.mock("@core/services/featureFlags", () => {
|
||||
return {
|
||||
featureFlags: {
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === "persistNodeDB") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function makeNode(num: number, extras: Record<string, any> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { NodeErrorType } from "@core/stores";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
|
||||
// Validates a new incoming node against existing nodes.
|
||||
// If valid, returns a node to store, else returns undefined.
|
||||
export function validateIncomingNode(
|
||||
newNode: Protobuf.Mesh.NodeInfo,
|
||||
setNodeError: (nodeNum: number, error: NodeErrorType) => void,
|
||||
getNodes: (
|
||||
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||
) => Protobuf.Mesh.NodeInfo[],
|
||||
): Protobuf.Mesh.NodeInfo | undefined {
|
||||
const num = newNode.num;
|
||||
const existingNodes = getNodes((node) => node.num === num);
|
||||
|
||||
if (existingNodes.length === 0) {
|
||||
// No existing node with this node number.
|
||||
// Check if the new node's public key (if present and not empty)
|
||||
// is already claimed by another existing node.
|
||||
if (
|
||||
newNode.user?.publicKey !== undefined &&
|
||||
newNode.user?.publicKey.length > 0
|
||||
) {
|
||||
const nodesWithSameKey = getNodes(
|
||||
(node) => node.user?.publicKey === newNode.user?.publicKey,
|
||||
);
|
||||
if (nodesWithSameKey.length > 0) {
|
||||
// This is a potential impersonation attempt.
|
||||
setNodeError(num, "DUPLICATE_PKI");
|
||||
return undefined; // drop newNode entirely
|
||||
}
|
||||
}
|
||||
return newNode; // No conflicts, accept newNode
|
||||
} else if (existingNodes.length === 1) {
|
||||
// One existing node with this node number.
|
||||
const oldNode = existingNodes[0];
|
||||
if (!oldNode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A public key is considered matching if the incoming key equals
|
||||
// the existing key, OR if the existing key is empty.
|
||||
const isKeyMatchingOrExistingEmpty =
|
||||
oldNode.user?.publicKey === newNode.user?.publicKey ||
|
||||
oldNode.user?.publicKey === undefined ||
|
||||
oldNode.user?.publicKey.length === 0;
|
||||
|
||||
if (isKeyMatchingOrExistingEmpty) {
|
||||
// Keys match or existing key was empty: trust the incoming node data completely.
|
||||
// This allows for legitimate updates to user info and other fields.
|
||||
return newNode;
|
||||
} else {
|
||||
// Keys do not match and existing key was not empty: potential impersonation attempt.
|
||||
setNodeError(num, "MISMATCH_PKI");
|
||||
return oldNode; // drop newNode fields and return old
|
||||
}
|
||||
} else {
|
||||
// Multiple existing nodes with the same node number
|
||||
// This should never happen, but if it does, we drop the new node entirely.
|
||||
setNodeError(num, "DUPLICATE_PKI");
|
||||
return undefined; // drop newNode entirely
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
|
||||
type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI";
|
||||
type NodeErrorType =
|
||||
| Protobuf.Mesh.Routing_Error
|
||||
| "MISMATCH_PKI"
|
||||
| "DUPLICATE_PKI";
|
||||
|
||||
type NodeError = {
|
||||
node: number;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export function evictOldestEntries<K, V>(
|
||||
map: Map<K, V>,
|
||||
maxSize: number,
|
||||
): void {
|
||||
// while loop in case maxSize is ever changed to be lower, to trim all the way down
|
||||
while (map.size > maxSize) {
|
||||
const firstKey = map.keys().next().value; // maps keep insertion order, so this is oldest
|
||||
if (firstKey !== undefined) {
|
||||
map.delete(firstKey);
|
||||
} else {
|
||||
break; // should not happen, but just in case
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ensureDefaultUser } from "@core/dto/NodeNumToNodeInfoDTO.ts";
|
||||
import 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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user