Browse Source

Persist message store

pull/814/head
philon- 11 months ago
parent
commit
c1d9db547e
  1. 4
      packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  2. 4
      packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  3. 4
      packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  4. 3
      packages/web/src/components/PageComponents/Connect/BLE.tsx
  5. 3
      packages/web/src/components/PageComponents/Connect/HTTP.tsx
  6. 3
      packages/web/src/components/PageComponents/Connect/Serial.tsx
  7. 4
      packages/web/src/components/PageComponents/Messages/MessageInput.tsx
  8. 12
      packages/web/src/components/PageComponents/Messages/MessageItem.tsx
  9. 1
      packages/web/src/core/services/dev-overrides.ts
  10. 13
      packages/web/src/core/stores/index.ts
  11. 368
      packages/web/src/core/stores/messageStore/index.ts
  12. 52
      packages/web/src/core/stores/nodeDBStore/index.ts
  13. 71
      packages/web/src/core/stores/utils/mergeNodeInfo.ts
  14. 14
      packages/web/src/core/stores/utils/useNewNodeNum.ts
  15. 25
      packages/web/src/core/subscriptions.ts
  16. 20
      packages/web/src/core/utils/sameKey.ts
  17. 26
      packages/web/src/pages/Messages.tsx

4
packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx

@ -8,7 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useMessageStore } from "@core/stores";
import { useMessages } from "@core/stores";
import { AlertTriangleIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
@ -22,7 +22,7 @@ export const DeleteMessagesDialog = ({
onOpenChange,
}: DeleteMessagesDialogProps) => {
const { t } = useTranslation("dialog");
const { deleteAllMessages } = useMessageStore();
const { deleteAllMessages } = useMessages();
const handleCloseDialog = () => {
onOpenChange(false);
};

4
packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -6,7 +6,7 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useMessageStore, useNodeDB } from "@core/stores";
import { useMessages, useNodeDB } from "@core/stores";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
@ -21,7 +21,7 @@ export const RefreshKeysDialog = ({
onOpenChange,
}: RefreshKeysDialogProps) => {
const { t } = useTranslation("dialog");
const { activeChat } = useMessageStore();
const { activeChat } = useMessages();
const { nodeErrors, getNode } = useNodeDB();
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();

4
packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts

@ -1,10 +1,10 @@
import { useDevice, useMessageStore, useNodeDB } from "@core/stores";
import { useDevice, useMessages, useNodeDB } from "@core/stores";
import { useCallback } from "react";
export function useRefreshKeysDialog() {
const { setDialogOpen } = useDevice();
const { removeNode, clearNodeError, getNodeError } = useNodeDB();
const { activeChat } = useMessageStore();
const { activeChat } = useMessages();
const handleCloseDialog = useCallback(() => {
setDialogOpen("refreshKeys", false);

3
packages/web/src/components/PageComponents/Connect/BLE.tsx

@ -20,7 +20,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const messageStore = useMessageStore();
const { addMessageStore } = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
@ -37,6 +37,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
const device = addDevice(id);
const nodeDB = addNodeDB(id);
const messageStore = addMessageStore(id);
const connection = new MeshDevice(transport, id);
connection.configure();

3
packages/web/src/components/PageComponents/Connect/HTTP.tsx

@ -31,7 +31,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const messageStore = useMessageStore();
const { addMessageStore } = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { control, handleSubmit, register } = useForm<FormData>({
@ -63,6 +63,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
const transport = await TransportHTTP.create(data.ip, data.tls);
const device = addDevice(id);
const nodeDB = addNodeDB(id);
const messageStore = addMessageStore(id);
const connection = new MeshDevice(transport, id);
connection.configure();

3
packages/web/src/components/PageComponents/Connect/Serial.tsx

@ -20,7 +20,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
const { addDevice } = useDeviceStore();
const { addNodeDB } = useNodeDBStore();
const messageStore = useMessageStore();
const { addMessageStore } = useMessageStore();
const { setSelectedDevice } = useAppStore();
const { t } = useTranslation();
@ -42,6 +42,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
const id = randId();
const device = addDevice(id);
const nodeDB = addNodeDB(id);
const messageStore = addMessageStore(id);
setSelectedDevice(id);
const transport = await TransportWebSerial.createFromPort(port);

4
packages/web/src/components/PageComponents/Messages/MessageInput.tsx

@ -1,6 +1,6 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useMessageStore } from "@core/stores";
import { useMessages } from "@core/stores";
import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react";
@ -13,7 +13,7 @@ export interface MessageInputProps {
}
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessageStore();
const { setDraft, getDraft, clearDraft } = useMessages();
const { t } = useTranslation("messages");
const calculateBytes = (text: string) => new Blob([text]).size;

12
packages/web/src/components/PageComponents/Messages/MessageItem.tsx

@ -6,12 +6,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import {
MessageState,
useDevice,
useMessageStore,
useNodeDB,
} from "@core/stores";
import { MessageState, useDevice, useNodeDB } from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import { cn } from "@core/utils/cn.ts";
import { type Protobuf, Types } from "@meshtastic/core";
@ -53,8 +48,7 @@ interface MessageItemProps {
export const MessageItem = ({ message }: MessageItemProps) => {
const { config } = useDevice();
const { getNode } = useNodeDB();
const { getMyNodeNum } = useMessageStore();
const { getNode, getMyNode } = useNodeDB();
const { t, i18n } = useTranslation("messages");
const MESSAGE_STATUS_MAP = useMemo(
@ -102,7 +96,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]);
const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
const myNodeNum = useMemo(() => getMyNode().num, [getMyNode]);
const { displayName, shortName, isFavorite } = useMemo(() => {
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");

1
packages/web/src/core/services/dev-overrides.ts

@ -6,5 +6,6 @@ console.log(`Dev mode: ${isDev}`);
if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
persistMessages: true,
});
}

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

368
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<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
export interface MessageBuckets {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
}
export interface MessageStore {
messages: MessageStore["messages"];
draft: Map<Types.Destination, string>;
nodeNum: number; // This device's node number
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
id: number;
myNodeNum: number | undefined;
messages: MessageBuckets;
drafts: Map<Types.Destination, string>;
// Ephemeral UI state (not persisted)
activeChat: number;
chatType: MessageType;
setNodeNum: (nodeNum: number) => void;
getMyNodeNum: () => number;
saveMessage: (message: Message) => void;
setMessageState: (params: SetMessageStateParams) => void;
getMessages: (params: GetMessagesParams) => Message[];
getDraft: (key: Types.Destination) => string;
setDraft: (key: Types.Destination, message: string) => void;
clearDraft: (key: Types.Destination) => void;
//clearAllDrafts: (key: Types.Destination) => void;
deleteAllMessages: () => void;
clearMessageByMessageId: (params: ClearMessageParams) => void;
clearDraft: (key: Types.Destination) => void;
}
// const CURRENT_STORE_VERSION = 0;
export interface MessageStoreState {
addMessageStore: (id: number) => MessageStore;
removeMessageStore: (id: number) => void;
getMessageStore: (id: number) => MessageStore | undefined;
getMessageStores: () => MessageStore[];
}
interface PrivateMessageStoreState extends MessageStoreState {
messageStores: Map<number, MessageStore>;
}
type MessageStoreData = {
id: number;
myNodeNum: number | undefined;
messages: MessageBuckets;
drafts: Map<Types.Destination, string>;
};
type MessageStorePersisted = {
messageStores: Map<number, MessageStoreData>;
};
function messageStoreFactory(
id: number,
get: () => PrivateMessageStoreState,
set: typeof useMessageStore.setState,
data?: Partial<MessageStoreData>,
): MessageStore {
const messages = data?.messages ?? {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
};
const drafts = data?.drafts ?? new Map<Types.Destination, string>();
const myNodeNum = data?.myNodeNum;
const activeChat = 0;
const chatType = MessageType.Broadcast;
return {
id,
myNodeNum,
messages,
drafts,
activeChat,
chatType,
export const useMessageStore = create<MessageStore>()(
// persist(
(set, get) => ({
messages: {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
},
draft: new Map<number, string>(),
activeChat: 0,
chatType: MessageType.Broadcast,
nodeNum: 0,
setNodeNum: (nodeNum) => {
set(
produce((state: MessageStore) => {
state.nodeNum = nodeNum;
produce<PrivateMessageStoreState>((draft) => {
const newStore = draft.messageStores.get(id);
if (!newStore) {
throw new Error(`No MessageStore found for id: ${id}`);
}
newStore.myNodeNum = nodeNum;
for (const [otherId, oldStore] of draft.messageStores) {
if (otherId === id || oldStore.myNodeNum !== nodeNum) {
continue;
}
// Adopt broadcast conversations (reuses inner Map references)
for (const [channelId, logMap] of oldStore.messages.broadcast) {
newStore.messages.broadcast.set(channelId, logMap);
}
// Adopt direct conversations
for (const [conversationId, logMap] of oldStore.messages.direct) {
newStore.messages.direct.set(conversationId, logMap);
}
// Adopt drafts
for (const [destination, draftText] of oldStore.drafts) {
newStore.drafts.set(destination, draftText);
}
// Drop old store
draft.messageStores.delete(otherId);
}
}),
);
},
getMyNodeNum: () => get().nodeNum,
// TODO: Cycle out old messages based on retention policy
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,16 @@ 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);
}
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<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 +233,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 +258,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 +322,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 +353,114 @@ export const useMessageStore = create<MessageStore>()(
}),
);
},
getDraft: (key) => {
return get().draft.get(key) ?? "";
},
setDraft: (key, message) => {
set(
produce((state: MessageStore) => {
state.draft.set(key, message);
}),
);
},
clearDraft: (key) => {
set(
produce((state: MessageStore) => {
state.draft.delete(key);
}),
);
},
deleteAllMessages: () => {
set(
produce((state: MessageStore) => {
state.messages.direct = new Map<ConversationId, MessageLogMap>();
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
}),
);
},
};
}
export const messageStoreInitializer: StateCreator<PrivateMessageStoreState> = (
set,
get,
) => ({
messageStores: new Map(),
addMessageStore: (id) => {
const existing = get().messageStores.get(id);
if (existing) {
return existing;
}
const nodeStore = messageStoreFactory(id, get, set);
set(
produce<PrivateMessageStoreState>((draft) => {
draft.messageStores.set(id, nodeStore);
// 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<PrivateMessageStoreState>((draft) => {
draft.messageStores.delete(id);
}),
);
},
getMessageStores: () => Array.from(get().messageStores.values()),
getMessageStore: (id) => get().messageStores.get(id),
});
const persistOptions: PersistOptions<
PrivateMessageStoreState,
MessageStorePersisted
> = {
name: "meshtastic-MessageStore-store",
storage: createStorage<MessageStorePersisted>(),
version: CURRENT_STORE_VERSION,
partialize: (s): MessageStorePersisted => ({
messageStores: new Map(
Array.from(s.messageStores.entries()).map(([id, db]) => [
id,
{
id: db.id,
myNodeNum: db.myNodeNum,
messages: db.messages,
drafts: db.drafts,
},
]),
),
}),
// {
// name: 'meshtastic-message-store',
// storage: storageWithMapSupport,
// version: CURRENT_STORE_VERSION,
// partialize: (state) => ({
// messages: state.messages,
// nodeNum: state.nodeNum,
// }),
// })
onRehydrateStorage: () => (state) => {
if (!state) {
return;
}
console.debug(
"MessageStoreStore: Rehydrating state with ",
state.messageStores.size,
" MessageStores -",
state.messageStores,
);
useMessageStore.setState(
produce<PrivateMessageStoreState>((draft) => {
const rebuilt = new Map<number, MessageStore>();
for (const [id, data] of (
draft.messageStores as unknown as Map<number, MessageStoreData>
).entries()) {
if (data.myNodeNum !== undefined) {
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
rebuilt.set(
id,
messageStoreFactory(
id,
useMessageStore.getState,
useMessageStore.setState,
data,
),
);
}
}
draft.messageStores = rebuilt;
}),
);
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistMessages = featureFlags.get("persistMessages");
console.debug(
`MessageStore: Persisting messages is ${persistMessages ? "enabled" : "disabled"}`,
);
export const useMessageStore = persistMessages
? createStore<
PrivateMessageStoreState,
[["zustand/persist", MessageStorePersisted]]
>(persist(messageStoreInitializer, persistOptions))
: createStore<PrivateMessageStoreState>()(messageStoreInitializer);

52
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<number, Protobuf.Mesh.NodeInfo>(
oldDB.nodeMap,
);
const mergedErrors = new Map<number, NodeError>(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<PrivateNodeDBState> = (
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);
}

71
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<T extends object, K extends keyof T = keyof T>(
destination: T,
source: Partial<T>,
exclude?: readonly K[],
): T {
const ex = new Set<keyof T>(exclude ?? []);
for (const key of Object.keys(source) as Array<keyof T>) {
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<Protobuf.Mesh.User>(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;
}

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

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

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

26
packages/web/src/pages/Messages.tsx

@ -12,7 +12,7 @@ import {
MessageState,
MessageType,
useDevice,
useMessageStore,
useMessages,
useNodeDB,
useSidebar,
} from "@core/stores";
@ -44,9 +44,9 @@ function SelectMessageChat() {
export const MessagesPage = () => {
const { channels, getUnreadCount, resetUnread, connection } = useDevice();
const { getNodes, getNode, hasNodeError } = useNodeDB();
const { getNodes, getNode, getMyNode, hasNodeError } = useNodeDB();
const { getMyNodeNum, getMessages, setMessageState } = useMessageStore();
const { getMessages, setMessageState } = useMessages();
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
@ -77,7 +77,10 @@ export const MessagesPage = () => {
useEffect(() => {
if (!type && !chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[0];
navigateToChat(MessageType.Broadcast, defaultChannel.index.toString());
navigateToChat(
MessageType.Broadcast,
defaultChannel?.index.toString() ?? "0",
);
}
}, [type, chatId, filteredChannels, navigateToChat]);
@ -138,7 +141,7 @@ export const MessagesPage = () => {
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeA: getMyNode().num,
nodeB: numericChatId,
messageId,
newState: MessageState.Ack,
@ -160,7 +163,7 @@ export const MessagesPage = () => {
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeA: getMyNode().num,
nodeB: numericChatId,
messageId: failedId,
newState: MessageState.Failed,
@ -168,14 +171,7 @@ export const MessagesPage = () => {
}
}
},
[
numericChatId,
chatType,
connection,
getMyNodeNum,
setMessageState,
isDirect,
],
[numericChatId, chatType, connection, getMyNode, setMessageState, isDirect],
);
const renderChatContent = () => {
@ -194,7 +190,7 @@ export const MessagesPage = () => {
<ChannelChat
messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNodeNum(),
nodeA: getMyNode().num,
nodeB: numericChatId,
}).reverse()}
/>

Loading…
Cancel
Save