diff --git a/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx b/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx index cdfe0d22..334ccb21 100644 --- a/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx +++ b/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx @@ -8,7 +8,7 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { useMessageStore } from "@core/stores"; +import { useMessages } from "@core/stores"; import { AlertTriangleIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -22,7 +22,7 @@ export const DeleteMessagesDialog = ({ onOpenChange, }: DeleteMessagesDialogProps) => { const { t } = useTranslation("dialog"); - const { deleteAllMessages } = useMessageStore(); + const { deleteAllMessages } = useMessages(); const handleCloseDialog = () => { onOpenChange(false); }; diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index 19a51e17..f7ba3ec7 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -6,7 +6,7 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { useMessageStore, useNodeDB } from "@core/stores"; +import { useMessages, useNodeDB } from "@core/stores"; import { LockKeyholeOpenIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; @@ -21,7 +21,7 @@ export const RefreshKeysDialog = ({ onOpenChange, }: RefreshKeysDialogProps) => { const { t } = useTranslation("dialog"); - const { activeChat } = useMessageStore(); + const { activeChat } = useMessages(); const { nodeErrors, getNode } = useNodeDB(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts index aa7352cb..40156a95 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,10 +1,10 @@ -import { useDevice, useMessageStore, useNodeDB } from "@core/stores"; +import { useDevice, useMessages, useNodeDB } from "@core/stores"; import { useCallback } from "react"; export function useRefreshKeysDialog() { const { setDialogOpen } = useDevice(); const { removeNode, clearNodeError, getNodeError } = useNodeDB(); - const { activeChat } = useMessageStore(); + const { activeChat } = useMessages(); const handleCloseDialog = useCallback(() => { setDialogOpen("refreshKeys", false); diff --git a/packages/web/src/components/PageComponents/Connect/BLE.tsx b/packages/web/src/components/PageComponents/Connect/BLE.tsx index 9d50b64c..21e32238 100644 --- a/packages/web/src/components/PageComponents/Connect/BLE.tsx +++ b/packages/web/src/components/PageComponents/Connect/BLE.tsx @@ -20,7 +20,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => { const { addDevice } = useDeviceStore(); const { addNodeDB } = useNodeDBStore(); - const messageStore = useMessageStore(); + const { addMessageStore } = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { t } = useTranslation(); @@ -37,6 +37,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => { const transport = await TransportWebBluetooth.createFromDevice(bleDevice); const device = addDevice(id); const nodeDB = addNodeDB(id); + const messageStore = addMessageStore(id); const connection = new MeshDevice(transport, id); connection.configure(); diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.tsx index b4ac3591..ebc24a77 100644 --- a/packages/web/src/components/PageComponents/Connect/HTTP.tsx +++ b/packages/web/src/components/PageComponents/Connect/HTTP.tsx @@ -31,7 +31,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => { const { addDevice } = useDeviceStore(); const { addNodeDB } = useNodeDBStore(); - const messageStore = useMessageStore(); + const { addMessageStore } = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { control, handleSubmit, register } = useForm({ @@ -63,6 +63,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => { const transport = await TransportHTTP.create(data.ip, data.tls); const device = addDevice(id); const nodeDB = addNodeDB(id); + const messageStore = addMessageStore(id); const connection = new MeshDevice(transport, id); connection.configure(); diff --git a/packages/web/src/components/PageComponents/Connect/Serial.tsx b/packages/web/src/components/PageComponents/Connect/Serial.tsx index 4015606a..477ded03 100644 --- a/packages/web/src/components/PageComponents/Connect/Serial.tsx +++ b/packages/web/src/components/PageComponents/Connect/Serial.tsx @@ -20,7 +20,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => { const { addDevice } = useDeviceStore(); const { addNodeDB } = useNodeDBStore(); - const messageStore = useMessageStore(); + const { addMessageStore } = useMessageStore(); const { setSelectedDevice } = useAppStore(); const { t } = useTranslation(); @@ -42,6 +42,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => { const id = randId(); const device = addDevice(id); const nodeDB = addNodeDB(id); + const messageStore = addMessageStore(id); setSelectedDevice(id); const transport = await TransportWebSerial.createFromPort(port); diff --git a/packages/web/src/components/PageComponents/Messages/MessageInput.tsx b/packages/web/src/components/PageComponents/Messages/MessageInput.tsx index 9afa506b..4a78d068 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageInput.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageInput.tsx @@ -1,6 +1,6 @@ import { Button } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; -import { useMessageStore } from "@core/stores"; +import { useMessages } from "@core/stores"; import type { Types } from "@meshtastic/core"; import { SendIcon } from "lucide-react"; import { startTransition, useState } from "react"; @@ -13,7 +13,7 @@ export interface MessageInputProps { } export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => { - const { setDraft, getDraft, clearDraft } = useMessageStore(); + const { setDraft, getDraft, clearDraft } = useMessages(); const { t } = useTranslation("messages"); const calculateBytes = (text: string) => new Blob([text]).size; diff --git a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx index 4f75ccef..acf89a63 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx @@ -6,12 +6,7 @@ import { TooltipProvider, TooltipTrigger, } from "@components/UI/Tooltip.tsx"; -import { - MessageState, - useDevice, - useMessageStore, - useNodeDB, -} from "@core/stores"; +import { MessageState, useDevice, useNodeDB } from "@core/stores"; import type { Message } from "@core/stores/messageStore/types.ts"; import { cn } from "@core/utils/cn.ts"; import { type Protobuf, Types } from "@meshtastic/core"; @@ -53,8 +48,7 @@ interface MessageItemProps { export const MessageItem = ({ message }: MessageItemProps) => { const { config } = useDevice(); - const { getNode } = useNodeDB(); - const { getMyNodeNum } = useMessageStore(); + const { getNode, getMyNode } = useNodeDB(); const { t, i18n } = useTranslation("messages"); const MESSAGE_STATUS_MAP = useMemo( @@ -102,7 +96,7 @@ export const MessageItem = ({ message }: MessageItemProps) => { return message.from != null ? getNode(message.from) : null; }, [getNode, message.from]); - const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]); + const myNodeNum = useMemo(() => getMyNode().num, [getMyNode]); const { displayName, shortName, isFavorite } = useMemo(() => { const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0"); diff --git a/packages/web/src/core/services/dev-overrides.ts b/packages/web/src/core/services/dev-overrides.ts index 98feba69..928c43a5 100644 --- a/packages/web/src/core/services/dev-overrides.ts +++ b/packages/web/src/core/services/dev-overrides.ts @@ -6,5 +6,6 @@ console.log(`Dev mode: ${isDev}`); if (isDev) { featureFlags.setOverrides({ persistNodeDB: true, + persistMessages: true, }); } diff --git a/packages/web/src/core/stores/index.ts b/packages/web/src/core/stores/index.ts index e6c73d19..9dff8004 100644 --- a/packages/web/src/core/stores/index.ts +++ b/packages/web/src/core/stores/index.ts @@ -1,5 +1,6 @@ import { useDeviceContext } from "@app/core/stores/utils/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 { @@ -19,13 +20,15 @@ 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 } from "@core/stores/sidebarStore"; +export { useNewNodeNum } from "@core/stores/utils/useNewNodeNum"; // Define hooks to access the stores export const useNodeDB = (): NodeDB => { @@ -43,3 +46,11 @@ export const useDevice = (): Device => { ); return device; }; +export const useMessages = (): MessageStore => { + const { deviceId } = useDeviceContext(); + + const device = useMessageStore( + (s) => s.getMessageStore(deviceId) ?? s.addMessageStore(deviceId), + ); + return device; +}; diff --git a/packages/web/src/core/stores/messageStore/index.ts b/packages/web/src/core/stores/messageStore/index.ts index a96e1008..b1cb838b 100644 --- a/packages/web/src/core/stores/messageStore/index.ts +++ b/packages/web/src/core/stores/messageStore/index.ts @@ -1,4 +1,4 @@ -// import { storageWithMapSupport } from "../storage/indexDB.ts"; +import { featureFlags } from "@core/services/featureFlags"; import type { ChannelId, ClearMessageParams, @@ -10,10 +10,15 @@ import type { NodeNum, SetMessageStateParams, } from "@core/stores/messageStore/types.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 +38,127 @@ export function getConversationId( return [node1, node2].sort((a, b) => a - b).join(":"); } -export interface MessageStore { - messages: { - direct: Map; - broadcast: Map; - }; +export interface MessageBuckets { + direct: Map; + broadcast: Map; } export interface MessageStore { - messages: MessageStore["messages"]; - draft: Map; - nodeNum: number; // This device's node number - activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast + id: number; + myNodeNum: number | undefined; + + messages: MessageBuckets; + drafts: Map; + + // Ephemeral UI state (not persisted) + activeChat: number; chatType: MessageType; setNodeNum: (nodeNum: number) => void; - getMyNodeNum: () => number; saveMessage: (message: Message) => void; setMessageState: (params: SetMessageStateParams) => void; getMessages: (params: GetMessagesParams) => Message[]; + getDraft: (key: Types.Destination) => string; setDraft: (key: Types.Destination, message: string) => void; + clearDraft: (key: Types.Destination) => void; + //clearAllDrafts: (key: Types.Destination) => void; + deleteAllMessages: () => void; clearMessageByMessageId: (params: ClearMessageParams) => void; - clearDraft: (key: Types.Destination) => void; } -// const CURRENT_STORE_VERSION = 0; +export interface MessageStoreState { + addMessageStore: (id: number) => MessageStore; + removeMessageStore: (id: number) => void; + getMessageStore: (id: number) => MessageStore | undefined; + getMessageStores: () => MessageStore[]; +} +interface PrivateMessageStoreState extends MessageStoreState { + messageStores: Map; +} + +type MessageStoreData = { + id: number; + myNodeNum: number | undefined; + + messages: MessageBuckets; + drafts: Map; +}; + +type MessageStorePersisted = { + messageStores: Map; +}; + +function messageStoreFactory( + id: number, + get: () => PrivateMessageStoreState, + set: typeof useMessageStore.setState, + data?: Partial, +): MessageStore { + const messages = data?.messages ?? { + direct: new Map(), + broadcast: new Map(), + }; + const drafts = data?.drafts ?? new Map(); + const myNodeNum = data?.myNodeNum; + const activeChat = 0; + const chatType = MessageType.Broadcast; + + return { + id, + myNodeNum, + messages, + drafts, + activeChat, + chatType, -export const useMessageStore = create()( - // persist( - (set, get) => ({ - messages: { - direct: new Map(), - broadcast: new Map(), - }, - draft: new Map(), - activeChat: 0, - chatType: MessageType.Broadcast, - nodeNum: 0, setNodeNum: (nodeNum) => { set( - produce((state: MessageStore) => { - state.nodeNum = nodeNum; + produce((draft) => { + const newStore = draft.messageStores.get(id); + if (!newStore) { + throw new Error(`No MessageStore found for id: ${id}`); + } + + newStore.myNodeNum = nodeNum; + + for (const [otherId, oldStore] of draft.messageStores) { + if (otherId === id || oldStore.myNodeNum !== nodeNum) { + continue; + } + + // Adopt broadcast conversations (reuses inner Map references) + for (const [channelId, logMap] of oldStore.messages.broadcast) { + newStore.messages.broadcast.set(channelId, logMap); + } + + // Adopt direct conversations + for (const [conversationId, logMap] of oldStore.messages.direct) { + newStore.messages.direct.set(conversationId, logMap); + } + + // Adopt drafts + for (const [destination, draftText] of oldStore.drafts) { + newStore.drafts.set(destination, draftText); + } + + // Drop old store + draft.messageStores.delete(otherId); + } }), ); }, - getMyNodeNum: () => get().nodeNum, + + // TODO: Cycle out old messages based on retention policy saveMessage: (message: Message) => { set( - produce((state: MessageStore) => { + produce((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + + let log: MessageLogMap | undefined; if (message.type === MessageType.Direct) { const conversationId = getConversationId(message.from, message.to); if (!state.messages.direct.has(conversationId)) { @@ -90,9 +167,9 @@ export const useMessageStore = create()( new Map(), ); } - state.messages.direct - .get(conversationId) - ?.set(message.messageId, message); + + log = state.messages.direct.get(conversationId); + log?.set(message.messageId, message); } else if (message.type === MessageType.Broadcast) { const channelId = message.channel as ChannelId; if (!state.messages.broadcast.has(channelId)) { @@ -101,9 +178,16 @@ export const useMessageStore = create()( new Map(), ); } - state.messages.broadcast - .get(channelId) - ?.set(message.messageId, message); + + log = state.messages.broadcast.get(channelId); + log?.set(message.messageId, message); + } + + while (log && log.size > MESSAGELOG_RETENTION_NUM) { + const firstKey = log.keys().next().value; // maps keep insertion order, so this is oldest + if (firstKey !== undefined) { + log.delete(firstKey); + } } }), ); @@ -111,7 +195,12 @@ export const useMessageStore = create()( setMessageState: (params: SetMessageStateParams) => { set( - produce((state: MessageStore) => { + produce((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + let messageLog: MessageLogMap | undefined; let targetMessage: Message | undefined; @@ -144,8 +233,13 @@ export const useMessageStore = create()( }), ); }, + getMessages: (params: GetMessagesParams): Message[] => { - const state = get(); + const state = get().messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + let messageMap: MessageLogMap | undefined; if (params.type === MessageType.Direct) { @@ -164,9 +258,61 @@ export const useMessageStore = create()( return messagesArray; }, + getDraft: (key) => { + const state = get().messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + + return state.drafts.get(key) ?? ""; + }, + setDraft: (key, message) => { + set( + produce((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + + state.drafts.set(key, message); + }), + ); + }, + clearDraft: (key) => { + set( + produce((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + + state.drafts.delete(key); + }), + ); + }, + + deleteAllMessages: () => { + set( + produce((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + + state.messages.direct = new Map(); + state.messages.broadcast = new Map(); + }), + ); + }, + clearMessageByMessageId: (params: ClearMessageParams) => { set( - produce((state: MessageStore) => { + produce((draft) => { + const state = draft.messageStores.get(id); + if (!state) { + throw new Error(`No MessageStore found for id: ${id}`); + } + let messageLog: MessageLogMap | undefined; let parentMap: Map; let parentKey: ConversationId | ChannelId; @@ -176,6 +322,7 @@ export const useMessageStore = create()( parentMap = state.messages.direct; messageLog = parentMap.get(parentKey); } else { + // Broadcast parentKey = params.channelId; parentMap = state.messages.broadcast; messageLog = parentMap.get(parentKey); @@ -206,39 +353,114 @@ export const useMessageStore = create()( }), ); }, - getDraft: (key) => { - return get().draft.get(key) ?? ""; - }, - setDraft: (key, message) => { - set( - produce((state: MessageStore) => { - state.draft.set(key, message); - }), - ); - }, - clearDraft: (key) => { - set( - produce((state: MessageStore) => { - state.draft.delete(key); - }), - ); - }, - deleteAllMessages: () => { - set( - produce((state: MessageStore) => { - state.messages.direct = new Map(); - state.messages.broadcast = new Map(); - }), - ); - }, + }; +} + +export const messageStoreInitializer: StateCreator = ( + set, + get, +) => ({ + messageStores: new Map(), + + addMessageStore: (id) => { + const existing = get().messageStores.get(id); + if (existing) { + return existing; + } + + const nodeStore = messageStoreFactory(id, get, set); + set( + produce((draft) => { + draft.messageStores.set(id, nodeStore); + + // If over limit, remove oldest inserted. FIFO + if (draft.messageStores.size > MESSAGESTORE_RETENTION_NUM) { + const firstKey = draft.messageStores.keys().next().value; + if (firstKey !== undefined) { + draft.messageStores.delete(firstKey); + } + } + }), + ); + + return nodeStore; + }, + removeMessageStore: (id) => { + set( + produce((draft) => { + draft.messageStores.delete(id); + }), + ); + }, + getMessageStores: () => Array.from(get().messageStores.values()), + getMessageStore: (id) => get().messageStores.get(id), +}); + +const persistOptions: PersistOptions< + PrivateMessageStoreState, + MessageStorePersisted +> = { + name: "meshtastic-MessageStore-store", + storage: createStorage(), + version: CURRENT_STORE_VERSION, + partialize: (s): MessageStorePersisted => ({ + messageStores: new Map( + Array.from(s.messageStores.entries()).map(([id, db]) => [ + id, + { + id: db.id, + myNodeNum: db.myNodeNum, + messages: db.messages, + drafts: db.drafts, + }, + ]), + ), }), - // { - // name: 'meshtastic-message-store', - // storage: storageWithMapSupport, - // version: CURRENT_STORE_VERSION, - // partialize: (state) => ({ - // messages: state.messages, - // nodeNum: state.nodeNum, - // }), - // }) + onRehydrateStorage: () => (state) => { + if (!state) { + return; + } + console.debug( + "MessageStoreStore: Rehydrating state with ", + state.messageStores.size, + " MessageStores -", + state.messageStores, + ); + + useMessageStore.setState( + produce((draft) => { + const rebuilt = new Map(); + for (const [id, data] of ( + draft.messageStores as unknown as Map + ).entries()) { + if (data.myNodeNum !== undefined) { + // Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate + rebuilt.set( + id, + messageStoreFactory( + id, + useMessageStore.getState, + useMessageStore.setState, + data, + ), + ); + } + } + draft.messageStores = rebuilt; + }), + ); + }, +}; + +// Add persist middleware on the store if the feature flag is enabled +const persistMessages = featureFlags.get("persistMessages"); +console.debug( + `MessageStore: Persisting messages is ${persistMessages ? "enabled" : "disabled"}`, ); + +export const useMessageStore = persistMessages + ? createStore< + PrivateMessageStoreState, + [["zustand/persist", MessageStorePersisted]] + >(persist(messageStoreInitializer, persistOptions)) + : createStore()(messageStoreInitializer); diff --git a/packages/web/src/core/stores/nodeDBStore/index.ts b/packages/web/src/core/stores/nodeDBStore/index.ts index c5cdb0d2..d0abf1b4 100644 --- a/packages/web/src/core/stores/nodeDBStore/index.ts +++ b/packages/web/src/core/stores/nodeDBStore/index.ts @@ -1,6 +1,7 @@ import { create } from "@bufbuild/protobuf"; import { featureFlags } from "@core/services/featureFlags"; import { createStorage } from "@core/stores/utils/indexDB.ts"; +import { mergeNodeInfo } from "@core/stores/utils/mergeNodeInfo"; import { Protobuf, type Types } from "@meshtastic/core"; import { produce } from "immer"; import { create as createStore, type StateCreator } from "zustand"; @@ -85,7 +86,15 @@ function nodeDBFactory( if (!nodeDB) { throw new Error(`No nodeDB found (id: ${id})`); } - nodeDB.nodeMap.set(node.num, node); + const prev = + nodeDB.nodeMap.get(node.num) ?? + create(Protobuf.Mesh.NodeInfoSchema); // defaults if first time + + const next = mergeNodeInfo(prev, node, (num, err) => { + nodeDB.setNodeError(num, err); + }); + + nodeDB.nodeMap.set(node.num, next); }), ), @@ -228,18 +237,37 @@ function nodeDBFactory( 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. + const mergedNodeMap = new Map( + oldDB.nodeMap, + ); + const mergedErrors = new Map(oldDB.nodeErrors); + + for (const [num, newNode] of newDB.nodeMap.entries()) { + const oldNode = mergedNodeMap.get(num); + if (!oldNode) { + // brand new node → add directly + mergedNodeMap.set(num, newNode); + continue; + } + // Merge fields; user conflicts will be flagged and old.user retained + const m = mergeNodeInfo(oldNode, newNode, (n, err) => + mergedErrors.set(n, { node: n, error: err }), ); + mergedNodeMap.set(num, m); + } + + // bring over any new errors that old didn't have + for (const [n, err] of newDB.nodeErrors.entries()) { + if (!mergedErrors.has(n)) { + mergedErrors.set(n, err); + } } - draft.nodeDBs.delete(key); + // finalize: move merged data into newDB and drop oldDB entry + newDB.nodeMap = mergedNodeMap; + newDB.nodeErrors = mergedErrors; + draft.nodeDBs.delete(oldDB.id); } } }), @@ -352,8 +380,8 @@ export const nodeDBInitializer: StateCreator = ( 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; + while (draft.nodeDBs.size > NODEDB_RETENTION_NUM) { + const firstKey = draft.nodeDBs.keys().next().value; // maps keep insertion order, so this is oldest if (firstKey !== undefined) { draft.nodeDBs.delete(firstKey); } diff --git a/packages/web/src/core/stores/utils/mergeNodeInfo.ts b/packages/web/src/core/stores/utils/mergeNodeInfo.ts new file mode 100644 index 00000000..43d8af94 --- /dev/null +++ b/packages/web/src/core/stores/utils/mergeNodeInfo.ts @@ -0,0 +1,71 @@ +import { create } from "@bufbuild/protobuf"; +import type { NodeErrorType } from "@core/stores"; +import { isEmptyKey, isSameKey } from "@core/utils/sameKey"; +import { Protobuf } from "@meshtastic/core"; + +function assignDefined( + destination: T, + source: Partial, + exclude?: readonly K[], +): T { + const ex = new Set(exclude ?? []); + for (const key of Object.keys(source) as Array) { + if (ex.has(key)) { + continue; + } + const val = source[key]; + if (val !== undefined) { + destination[key] = val as T[typeof key]; + } + } + return destination; +} + +export function mergeNodeInfo( + oldNode: Protobuf.Mesh.NodeInfo, + newNode: Protobuf.Mesh.NodeInfo, + setNodeError: (nodeNum: number, error: NodeErrorType) => void, +): Protobuf.Mesh.NodeInfo { + const merged = create(Protobuf.Mesh.NodeInfoSchema, oldNode); + + if (newNode.user !== undefined) { + const oldKey = oldNode.user?.publicKey; + const newKey = newNode.user.publicKey; + const ok = + isEmptyKey(newKey) || + (isEmptyKey(oldKey) && isEmptyKey(newKey)) || + (!isEmptyKey(oldKey) && !isEmptyKey(newKey) && isSameKey(oldKey, newKey)); + + if (!ok) { + setNodeError(merged.num, "MISMATCH_PKI"); + return merged; // drop all untrusted fields + } + + const baseUser = oldNode.user + ? create(Protobuf.Mesh.UserSchema, oldNode.user) + : create(Protobuf.Mesh.UserSchema); + merged.user = assignDefined(baseUser, newNode.user); + } + + if (newNode.position !== undefined) { + const basePosition = oldNode.position + ? create(Protobuf.Mesh.PositionSchema, oldNode.position) + : create(Protobuf.Mesh.PositionSchema); + merged.position = assignDefined(basePosition, newNode.position); + } + + if (newNode.deviceMetrics !== undefined) { + const baseMetrics = oldNode.deviceMetrics + ? create(Protobuf.Telemetry.DeviceMetricsSchema, oldNode.deviceMetrics) + : create(Protobuf.Telemetry.DeviceMetricsSchema); + merged.deviceMetrics = assignDefined(baseMetrics, newNode.deviceMetrics); + } + + assignDefined(merged, newNode, [ + "user", + "position", + "deviceMetrics", + ] as const); + + return merged; +} diff --git a/packages/web/src/core/stores/utils/useNewNodeNum.ts b/packages/web/src/core/stores/utils/useNewNodeNum.ts new file mode 100644 index 00000000..811807cc --- /dev/null +++ b/packages/web/src/core/stores/utils/useNewNodeNum.ts @@ -0,0 +1,14 @@ +import { useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores"; +import type { Protobuf } from "@meshtastic/core"; + +export function useNewNodeNum( + id: number, + nodeInfo: Protobuf.Mesh.MyNodeInfo, +): void { + useDeviceStore.getState().getDevice(id)?.setHardware(nodeInfo); + useNodeDBStore.getState().getNodeDB(id)?.setNodeNum(nodeInfo.myNodeNum); + useMessageStore + .getState() + .getMessageStore(id) + ?.setNodeNum(nodeInfo.myNodeNum); +} diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 4c1791f0..b7147ec6 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -5,9 +5,9 @@ import { type MessageStore, MessageType, type NodeDB, + useNewNodeNum, } from "@core/stores"; import { type MeshDevice, Protobuf } from "@meshtastic/core"; -import { fromByteArray } from "base64-js"; export const subscribeAll = ( device: Device, @@ -57,9 +57,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 +72,7 @@ export const subscribeAll = ( connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { const nodeWithUser = ensureDefaultUser(nodeInfo); - if (nodeWithUser.num !== myNodeNum && nodeDB.getNode(nodeWithUser.num)) { - const oldPublicKey = fromByteArray( - nodeDB.getNode(nodeWithUser.num)?.user?.publicKey ?? new Uint8Array(), - ); - const newPublicKey = fromByteArray( - nodeWithUser.user?.publicKey ?? new Uint8Array(), - ); - - if (oldPublicKey !== newPublicKey) { - console.warn( - `Node ${nodeWithUser.user?.longName} (${nodeWithUser.num}) has a different public key than expected: Expected ${oldPublicKey} but got ${newPublicKey}`, - ); - nodeDB.setNodeError(nodeWithUser.num, "MISMATCH_PKI"); - - // TODO: Handle this error case properly (refactor PKI dialog?) - } - } - + // PKI sanity check is handled inside nodeDB.addNode nodeDB.addNode(nodeWithUser); }); diff --git a/packages/web/src/core/utils/sameKey.ts b/packages/web/src/core/utils/sameKey.ts new file mode 100644 index 00000000..2dbc67fb --- /dev/null +++ b/packages/web/src/core/utils/sameKey.ts @@ -0,0 +1,20 @@ +export function isEmptyKey(key?: Uint8Array): boolean { + return !key || key.length === 0; +} + +export function isSameKey(a?: Uint8Array, b?: Uint8Array): boolean { + if (isEmptyKey(a) && isEmptyKey(b)) { + return true; + } + if (!a || !b) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx index 288f28e4..d31ee224 100644 --- a/packages/web/src/pages/Messages.tsx +++ b/packages/web/src/pages/Messages.tsx @@ -12,7 +12,7 @@ import { MessageState, MessageType, useDevice, - useMessageStore, + useMessages, useNodeDB, useSidebar, } from "@core/stores"; @@ -44,9 +44,9 @@ function SelectMessageChat() { export const MessagesPage = () => { const { channels, getUnreadCount, resetUnread, connection } = useDevice(); - const { getNodes, getNode, hasNodeError } = useNodeDB(); + const { getNodes, getNode, getMyNode, hasNodeError } = useNodeDB(); - const { getMyNodeNum, getMessages, setMessageState } = useMessageStore(); + const { getMessages, setMessageState } = useMessages(); const { type, chatId } = useParams({ from: messagesWithParamsRoute.id }); @@ -77,7 +77,10 @@ export const MessagesPage = () => { useEffect(() => { if (!type && !chatId && filteredChannels.length > 0) { const defaultChannel = filteredChannels[0]; - navigateToChat(MessageType.Broadcast, defaultChannel.index.toString()); + navigateToChat( + MessageType.Broadcast, + defaultChannel?.index.toString() ?? "0", + ); } }, [type, chatId, filteredChannels, navigateToChat]); @@ -138,7 +141,7 @@ export const MessagesPage = () => { } else { setMessageState({ type: MessageType.Direct, - nodeA: getMyNodeNum(), + nodeA: getMyNode().num, nodeB: numericChatId, messageId, newState: MessageState.Ack, @@ -160,7 +163,7 @@ export const MessagesPage = () => { } else { setMessageState({ type: MessageType.Direct, - nodeA: getMyNodeNum(), + nodeA: getMyNode().num, nodeB: numericChatId, messageId: failedId, newState: MessageState.Failed, @@ -168,14 +171,7 @@ export const MessagesPage = () => { } } }, - [ - numericChatId, - chatType, - connection, - getMyNodeNum, - setMessageState, - isDirect, - ], + [numericChatId, chatType, connection, getMyNode, setMessageState, isDirect], ); const renderChatContent = () => { @@ -194,7 +190,7 @@ export const MessagesPage = () => {