diff --git a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx index 94d31a93..41d82570 100644 --- a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx +++ b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.test.tsx @@ -4,17 +4,12 @@ import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx"; const mockFactoryResetDevice = vi.fn(); const mockRemoveDevice = vi.fn(); -const mockRemoveMessageStore = vi.fn(); const mockToast = vi.fn(); vi.mock("@core/stores", () => { - // Make each store a callable fn (like a Zustand hook), and attach .getState() const useDeviceStore = Object.assign(vi.fn(), { getState: () => ({ removeDevice: mockRemoveDevice }), }); - const useMessageStore = Object.assign(vi.fn(), { - getState: () => ({ removeMessageStore: mockRemoveMessageStore }), - }); return { CurrentDeviceContext: { @@ -25,7 +20,6 @@ vi.mock("@core/stores", () => { connection: { factoryResetDevice: mockFactoryResetDevice }, }), useDeviceStore, - useMessageStore, }; }); @@ -40,12 +34,10 @@ describe("FactoryResetDeviceDialog", () => { mockOnOpenChange.mockClear(); mockFactoryResetDevice.mockReset(); mockRemoveDevice.mockClear(); - mockRemoveMessageStore.mockClear(); mockToast.mockClear(); }); - it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => { - // Control the promise returned by factoryResetDevice + it("calls factoryResetDevice, closes dialog, and after reset resolves removes the device", async () => { let resolveReset: (() => void) | undefined; mockFactoryResetDevice.mockImplementation( () => @@ -57,20 +49,16 @@ describe("FactoryResetDeviceDialog", () => { render(); fireEvent.click(screen.getByRole("button", { name: "Factory Reset Device" })); - // Called immediately expect(mockFactoryResetDevice).toHaveBeenCalledTimes(1); - // DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask await waitFor(() => { expect(mockOnOpenChange).toHaveBeenCalledTimes(1); expect(mockOnOpenChange).toHaveBeenCalledWith(false); }); - // Resolve the reset resolveReset?.(); expect(mockRemoveDevice).toHaveBeenCalledTimes(1); - expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1); }); it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => { @@ -84,6 +72,5 @@ describe("FactoryResetDeviceDialog", () => { expect(mockFactoryResetDevice).not.toHaveBeenCalled(); expect(mockRemoveDevice).not.toHaveBeenCalled(); - expect(mockRemoveMessageStore).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx index afd32e14..42c90fca 100644 --- a/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx +++ b/packages/web/src/components/Dialog/FactoryResetDeviceDialog/FactoryResetDeviceDialog.tsx @@ -1,5 +1,5 @@ import { toast } from "@core/hooks/useToast.ts"; -import { useDevice, useDeviceStore, useMessageStore } from "@core/stores"; +import { useDevice, useDeviceStore } from "@core/stores"; import { useTranslation } from "react-i18next"; import { DialogWrapper } from "../DialogWrapper.tsx"; @@ -23,7 +23,6 @@ export const FactoryResetDeviceDialog = ({ open, onOpenChange }: FactoryResetDev // The device will be wiped and disconnected without resolving the promise // so we proceed to clear all data associated with the device immediately useDeviceStore.getState().removeDevice(id); - useMessageStore.getState().removeMessageStore(id); // Reload the app to ensure all ephemeral state is cleared window.location.href = "/"; diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx index fbfdec39..2098e04d 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx @@ -1,4 +1,4 @@ -import { CurrentDeviceContext, useDeviceStore, useMessageStore } from "@core/stores"; +import { CurrentDeviceContext, useDeviceStore } from "@core/stores"; import { MeshRegistry } from "@meshtastic/sdk"; import { MeshRegistryProvider } from "@meshtastic/sdk-react"; import { render } from "@testing-library/react"; @@ -6,16 +6,8 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; -vi.mock("@core/stores", async () => { - const actual = (await vi.importActual("@core/stores")) as typeof import("@core/stores"); - return { - ...actual, - useMessageStore: vi.fn(), - }; -}); vi.mock("./useRefreshKeysDialog"); -const mockUseMessageStore = vi.mocked(useMessageStore); const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); const getInitialState = () => @@ -33,20 +25,17 @@ afterEach(() => { vi.restoreAllMocks(); }); -test("does not render dialog if no error exists for active chat", () => { +test("does not render dialog when there are no node errors", () => { const deviceId = 1; - const activeChatNum = 54321; useDeviceStore.getState().addDevice(deviceId); - mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); mockUseRefreshKeysDialog.mockReturnValue({ handleCloseDialog: vi.fn(), handleNodeRemove: vi.fn(), }); - // Empty MeshRegistry so the SDK adapter hooks (useNodeAsProto) do not - // throw when looking up the missing-key node — they return undefined. + // Empty MeshRegistry so useNodeErrors returns the empty fallback. const registry = new MeshRegistry(); const { container } = render( diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index 4da22a28..27cb90b9 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -7,8 +7,7 @@ import { DialogTitle, } from "@components/UI/Dialog.tsx"; import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts"; -import { useMessages } from "@core/stores"; -import { useNodeError } from "@meshtastic/sdk-react"; +import { useNodeErrors } from "@meshtastic/sdk-react"; import { LockKeyholeOpenIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; @@ -20,8 +19,7 @@ export interface RefreshKeysDialogProps { export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => { const { t } = useTranslation("dialog"); - const { activeChat } = useMessages(); - const nodeError = useNodeError(activeChat); + const nodeError = useNodeErrors()[0]; const nodeWithError = useNodeAsProto(nodeError?.node ?? 0); 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 3694f0b9..d1b1046d 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,24 +1,22 @@ -import { useDevice, useMessages } from "@core/stores"; -import { useActiveClient } from "@meshtastic/sdk-react"; +import { useDevice } from "@core/stores"; +import { useActiveClient, useNodeErrors } from "@meshtastic/sdk-react"; import { useCallback } from "react"; export function useRefreshKeysDialog() { const { setDialogOpen } = useDevice(); const meshClient = useActiveClient(); - const { activeChat } = useMessages(); + const error = useNodeErrors()[0]; const handleCloseDialog = useCallback(() => { setDialogOpen("refreshKeys", false); }, [setDialogOpen]); const handleNodeRemove = useCallback(() => { - if (!meshClient) return; - const error = meshClient.nodes.errorFor(activeChat); - if (!error) return; - meshClient.nodes.clearError(activeChat); + if (!meshClient || !error) return; + meshClient.nodes.clearError(error.node); handleCloseDialog(); void meshClient.nodes.remove(error.node); - }, [meshClient, activeChat, handleCloseDialog]); + }, [meshClient, error, handleCloseDialog]); return { handleCloseDialog, diff --git a/packages/web/src/components/PageComponents/Messages/ChannelChat.tsx b/packages/web/src/components/PageComponents/Messages/ChannelChat.tsx index 5335b00b..67d17a1f 100644 --- a/packages/web/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/packages/web/src/components/PageComponents/Messages/ChannelChat.tsx @@ -1,7 +1,7 @@ import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; import { Separator } from "@components/UI/Separator"; import { Skeleton } from "@components/UI/Skeleton.tsx"; -import type { Message } from "@core/stores/messageStore/types.ts"; +import type { Message } from "@core/stores/messageStore"; import type { TFunction } from "i18next"; import { InboxIcon } from "lucide-react"; import { Fragment, Suspense, useMemo } from "react"; diff --git a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx index e03b4c42..6b975bc9 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx @@ -7,8 +7,8 @@ import { TooltipTrigger, } from "@components/UI/Tooltip.tsx"; import { useMyNodeAsProto, useNodeAsProto } from "@core/hooks/useNodesAsProto.ts"; -import { MessageState, useAppStore, useDevice } from "@core/stores"; -import type { Message } from "@core/stores/messageStore/types.ts"; +import { useAppStore, useDevice } from "@core/stores"; +import { type Message, MessageState } from "@core/stores/messageStore"; import { cn } from "@core/utils/cn.ts"; import { type Protobuf, Types } from "@meshtastic/sdk"; import type { LucideIcon } from "lucide-react"; diff --git a/packages/web/src/core/hooks/useChatLegacy.ts b/packages/web/src/core/hooks/useChatLegacy.ts index 19accc28..8f31d177 100644 --- a/packages/web/src/core/hooks/useChatLegacy.ts +++ b/packages/web/src/core/hooks/useChatLegacy.ts @@ -1,5 +1,8 @@ -import { MessageState as LegacyMessageState, MessageType } from "@core/stores"; -import type { Message as LegacyMessage } from "@core/stores/messageStore/types.ts"; +import { + type Message as LegacyMessage, + MessageState as LegacyMessageState, + MessageType, +} from "@core/stores/messageStore"; import type { Message as SdkMessage } from "@meshtastic/sdk"; import { MessageState as SdkMessageState, type Types } from "@meshtastic/sdk"; import { useChat, useDirectChat } from "@meshtastic/sdk-react"; diff --git a/packages/web/src/core/hooks/useNewNodeNum.ts b/packages/web/src/core/hooks/useNewNodeNum.ts index 41b62192..5e9e1f15 100644 --- a/packages/web/src/core/hooks/useNewNodeNum.ts +++ b/packages/web/src/core/hooks/useNewNodeNum.ts @@ -1,7 +1,6 @@ -import { useDeviceStore, useMessageStore } from "@core/stores"; +import { useDeviceStore } from "@core/stores"; import type { Protobuf } from "@meshtastic/sdk"; export function useNewNodeNum(id: number, nodeInfo: Protobuf.Mesh.MyNodeInfo): void { useDeviceStore.getState().getDevice(id)?.setHardware(nodeInfo); - useMessageStore.getState().getMessageStore(id)?.setNodeNum(nodeInfo.myNodeNum); } diff --git a/packages/web/src/core/stores/index.ts b/packages/web/src/core/stores/index.ts index a4d6f601..372bda83 100644 --- a/packages/web/src/core/stores/index.ts +++ b/packages/web/src/core/stores/index.ts @@ -1,6 +1,5 @@ import { useDeviceContext } from "@core/hooks/useDeviceContext.ts"; import { type Device, useDeviceStore } from "@core/stores/deviceStore/index.ts"; -import { type MessageStore, useMessageStore } from "@core/stores/messageStore/index.ts"; export { CurrentDeviceContext, @@ -31,12 +30,7 @@ export type { ValidModuleConfigType, WaypointWithMetadata, } from "@core/stores/deviceStore/types.ts"; -export { - MessageState, - type MessageStore, - MessageType, - useMessageStore, -} from "@core/stores/messageStore"; +export { MessageState, MessageType } from "@core/stores/messageStore"; export { SidebarProvider, useSidebar, // TODO: Bring hook into this file @@ -51,10 +45,3 @@ export const useDevice = (): Device => { const device = useDeviceStore((s) => s.getDevice(deviceId) ?? s.addDevice(deviceId)); 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 5e8f0ca4..55eaf993 100644 --- a/packages/web/src/core/stores/messageStore/index.ts +++ b/packages/web/src/core/stores/messageStore/index.ts @@ -1,26 +1,11 @@ -import { featureFlags } from "@core/services/featureFlags"; -import type { - ChannelId, - ClearMessageParams, - ConversationId, - GetMessagesParams, - Message, - MessageId, - MessageLogMap, - 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/sdk"; -import { produce } from "immer"; -import { create as createStore, type StateCreator } from "zustand"; -import { type PersistOptions, persist } from "zustand/middleware"; +/** + * Legacy enums and message-shape types preserved for components that still + * read from the SDK chat slice via the `useChatLegacy` adapter. The Zustand + * messageStore that previously lived here has been retired — chat + * persistence is owned by the SDK ChatClient + SqlocalMessageRepository. + */ -const IDB_KEY_NAME = "meshtastic-message-store"; -const CURRENT_STORE_VERSION = 0; -const MESSAGESTORE_RETENTION_NUM = 10; -const MESSAGELOG_RETENTION_NUM = 1000; // Max messages per conversation/channel +import type { Types } from "@meshtastic/sdk"; export enum MessageState { Ack = "ack", @@ -33,395 +18,27 @@ export enum MessageType { Broadcast = "broadcast", } +export type NodeNum = number; +export type MessageId = number; +export type ChannelId = Types.ChannelNumber; +export type ConversationId = string; + +interface MessageBase { + channel: Types.ChannelNumber; + to: number; + from: number; + date: number; + messageId: number; + state: MessageState; + message: string; +} + +interface GenericMessage extends MessageBase { + type: T; +} + +export type Message = GenericMessage | GenericMessage; + export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId { return [node1, node2].sort((a, b) => a - b).join(":"); } - -export interface MessageBuckets { - direct: Map; - broadcast: Map; -} - -type MessageStoreData = { - // Persisted data - id: number; - myNodeNum: number | undefined; - messages: MessageBuckets; - drafts: Map; -}; - -export interface MessageStore extends MessageStoreData { - // Ephemeral state (not persisted) - activeChat: number; - chatType: MessageType; - - setNodeNum: (nodeNum: number) => void; - 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; -} - -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 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, - - setNodeNum: (nodeNum) => { - set( - 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); - } - }), - ); - }, - - saveMessage: (message: Message) => { - set( - 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)) { - state.messages.direct.set(conversationId, new Map()); - } - - 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)) { - state.messages.broadcast.set(channelId, new Map()); - } - - log = state.messages.broadcast.get(channelId); - log?.set(message.messageId, message); - } - - if (log) { - // Enforce retention limit - evictOldestEntries(log, MESSAGELOG_RETENTION_NUM); - } - }), - ); - }, - - setMessageState: (params: SetMessageStateParams) => { - set( - 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; - - if (params.type === MessageType.Direct) { - const conversationId = getConversationId(params.nodeA, params.nodeB); - messageLog = state.messages.direct.get(conversationId); - if (messageLog) { - targetMessage = messageLog.get(params.messageId); - } - } else { - // Broadcast - messageLog = state.messages.broadcast.get(params.channelId); - if (messageLog) { - targetMessage = messageLog.get(params.messageId); - } - } - - if (targetMessage) { - targetMessage.state = params.newState ?? MessageState.Ack; - } else { - console.warn( - `Message or conversation/channel not found for state update. Params: ${JSON.stringify( - params, - )}`, - ); - } - }), - ); - }, - - getMessages: (params: GetMessagesParams): Message[] => { - 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) { - const conversationId = getConversationId(params.nodeA, params.nodeB); - messageMap = state.messages.direct.get(conversationId); - } else { - messageMap = state.messages.broadcast.get(params.channelId); - } - - if (messageMap === undefined) { - return []; - } - - const messagesArray = Array.from(messageMap.values()); - messagesArray.sort((a, b) => a.date - b.date); - 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((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; - - if (params.type === MessageType.Direct) { - parentKey = getConversationId(params.nodeA, params.nodeB); - parentMap = state.messages.direct; - messageLog = parentMap.get(parentKey); - } else { - // Broadcast - parentKey = params.channelId; - parentMap = state.messages.broadcast; - messageLog = parentMap.get(parentKey); - } - - if (messageLog) { - const deleted = messageLog.delete(params.messageId); - - if (deleted) { - console.log( - `Deleted message ${params.messageId} from ${params.type} message ${parentKey}`, - ); - // Clean up empty MessageLogMap and its entry in the parent map - if (messageLog.size === 0) { - parentMap.delete(parentKey); - console.log(`Cleaned up empty message entry for ${parentKey}`); - } - } else { - console.warn( - `Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`, - ); - } - } else { - console.warn(`Message entry ${parentKey} not found for message deletion.`); - } - }), - ); - }, - }; -} - -export const messageStoreInitializer: StateCreator = (set, get) => ({ - messageStores: new Map(), - - addMessageStore: (id) => { - const existing = get().messageStores.get(id); - if (existing) { - return existing; - } - - const nodeStore = messageStoreFactory(id, get, set); - set( - produce((draft) => { - draft.messageStores.set(id, nodeStore); - - // Enforce retention limit - evictOldestEntries(draft.messageStores, MESSAGESTORE_RETENTION_NUM); - }), - ); - - return nodeStore; - }, - removeMessageStore: (id) => { - set( - produce((draft) => { - draft.messageStores.delete(id); - }), - ); - }, - getMessageStores: () => Array.from(get().messageStores.values()), - getMessageStore: (id) => get().messageStores.get(id), -}); - -const persistOptions: PersistOptions = { - name: IDB_KEY_NAME, - 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, - }, - ]), - ), - }), - 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( - persist(messageStoreInitializer, persistOptions), - ) - : createStore()(messageStoreInitializer); diff --git a/packages/web/src/core/stores/messageStore/messageStore.test.ts b/packages/web/src/core/stores/messageStore/messageStore.test.ts deleted file mode 100644 index fafe1f39..00000000 --- a/packages/web/src/core/stores/messageStore/messageStore.test.ts +++ /dev/null @@ -1,727 +0,0 @@ -/** biome-ignore-all lint/style/noNonNullAssertion: */ -import { Types } from "@meshtastic/sdk"; -import { setAutoFreeze } from "immer"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getConversationId, MessageState, MessageType } from "./index.ts"; -import type { ChannelId, Message } from "./types.ts"; - -const idbMem = new Map(); -vi.mock("idb-keyval", () => ({ - get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))), - set: vi.fn((key: string, val: string) => { - idbMem.set(key, val); - return Promise.resolve(); - }), - del: vi.fn((k: string) => { - idbMem.delete(k); - return Promise.resolve(); - }), -})); - -async function freshStore(persist = false) { - vi.resetModules(); - - // suppress console output from the store during tests (for github actions) - vi.spyOn(console, "debug").mockImplementation(() => {}); - vi.spyOn(console, "log").mockImplementation(() => {}); - vi.spyOn(console, "info").mockImplementation(() => {}); - - // Mock feature flag for persistence - vi.doMock("@core/services/featureFlags", () => ({ - featureFlags: { - get: vi.fn((key: string) => (key === "persistMessages" ? persist : false)), - }, - })); - - const mod = await import("./index.ts"); - return mod; -} - -const myNodeNum = 111; -const otherNodeNum1 = 222; -const otherNodeNum2 = 333; -const broadcastChannel: ChannelId = 0; - -const directMessageToOther1: Message = { - type: MessageType.Direct, - from: myNodeNum, - to: otherNodeNum1, - channel: 0, - date: Date.now(), - messageId: 101, - state: MessageState.Waiting, - message: "Hello other 1 from me", -}; - -const directMessageFromOther1: Message = { - type: MessageType.Direct, - from: otherNodeNum1, - to: myNodeNum, - channel: 0, - date: Date.now() + 1000, - messageId: 102, - state: MessageState.Waiting, - message: "Hello me from other 1", -}; - -const directMessageToOther2: Message = { - type: MessageType.Direct, - from: myNodeNum, - to: otherNodeNum2, - channel: 0, - date: Date.now() + 2000, - messageId: 103, - state: MessageState.Waiting, - message: "Hello other 2 from me", -}; - -const broadcastMessage1: Message = { - type: MessageType.Broadcast, - from: otherNodeNum1, - to: 0xffffffff, - channel: broadcastChannel, - date: Date.now() + 3000, - messageId: 201, - state: MessageState.Waiting, - message: "Broadcast message 1", -}; - -const broadcastMessage2: Message = { - type: MessageType.Broadcast, - from: myNodeNum, - to: 0xffffffff, - channel: broadcastChannel, - date: Date.now() + 4000, - messageId: 202, - state: MessageState.Waiting, - message: "Broadcast message 2", -}; - -describe("MessageStore persistence & rehydrate", () => { - beforeEach(() => { - 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); - }); - - describe("saveMessage", async () => { - const { useMessageStore } = await freshStore(); - const state = useMessageStore.getState(); - state.addMessageStore(123); - - it("should save a direct message with correct Map structure", () => { - state.getMessageStore(123)?.saveMessage(directMessageToOther1); - const conversationId = getConversationId( - directMessageToOther1.from, - directMessageToOther1.to, - ); - - const store = state.getMessageStore(123)!; - - // Check if the conversation Map exists - 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 - expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true); - // Check the message content - expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1); - }); - - it("should save a broadcast message with correct Map structure", () => { - state.getMessageStore(123)?.saveMessage(broadcastMessage1); - const store = state.getMessageStore(123)!; - const channelId = broadcastMessage1.channel; - - 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(broadcastMessage1); - }); - - it("should save multiple messages correctly", () => { - state.getMessageStore(123)?.saveMessage(directMessageToOther1); - state.getMessageStore(123)?.saveMessage(directMessageFromOther1); - state.getMessageStore(123)?.saveMessage(broadcastMessage1); - - const store = state.getMessageStore(123)!; - - const convId1 = getConversationId(myNodeNum, otherNodeNum1); - expect(store.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual( - directMessageToOther1, - ); - - expect(store.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual( - directMessageFromOther1, - ); - - const channelId = broadcastMessage1.channel; - expect(store.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual( - broadcastMessage1, - ); - }); - }); - - describe("getMessages", async () => { - const { useMessageStore } = await freshStore(); - const state = useMessageStore.getState(); - state.addMessageStore(123); - - beforeEach(() => { - 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 = state.getMessageStore(123)!.getMessages({ - type: MessageType.Broadcast, - channelId: broadcastChannel, - }); - expect(messages).toHaveLength(2); - expect(messages[0]).toEqual(broadcastMessage1); - expect(messages[1]).toEqual(broadcastMessage2); - }); - - it("should return empty array for broadcast if channel has no messages", () => { - const messages = state.getMessageStore(123)!.getMessages({ - type: MessageType.Broadcast, - channelId: Types.ChannelNumber.Channel1, - }); - expect(messages).toEqual([]); - }); - - it("should return combined direct messages for a specific chat pair, sorted by date", () => { - const messages = state.getMessageStore(123)!.getMessages({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum1, - }); - expect(messages).toHaveLength(2); - expect(messages[0]).toEqual(directMessageToOther1); - expect(messages[1]).toEqual(directMessageFromOther1); - }); - - it("should return only relevant direct messages for a different chat pair", () => { - const messages = state.getMessageStore(123)!.getMessages({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum2, - }); - expect(messages).toHaveLength(1); - expect(messages[0]).toEqual(directMessageToOther2); - }); - - it("should return empty array for direct chat if no messages exist", () => { - const messages = state.getMessageStore(123)!.getMessages({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: 999, - }); - expect(messages).toEqual([]); - }); - }); - - describe("setMessageState", async () => { - const { useMessageStore } = await freshStore(); - const state = useMessageStore.getState(); - state.addMessageStore(123); - - beforeEach(() => { - 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", () => { - state.getMessageStore(123)!.setMessageState({ - type: MessageType.Direct, - nodeA: directMessageToOther1.from, - nodeB: directMessageToOther1.to, - messageId: directMessageToOther1.messageId, - newState: MessageState.Ack, - }); - const conversationId = getConversationId( - directMessageToOther1.from, - directMessageToOther1.to, - ); - 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", () => { - state.getMessageStore(123)!.setMessageState({ - type: MessageType.Direct, - nodeA: directMessageFromOther1.from, - nodeB: directMessageFromOther1.to, - messageId: directMessageFromOther1.messageId, - newState: MessageState.Failed, - }); - const conversationId = getConversationId( - directMessageFromOther1.from, - directMessageFromOther1.to, - ); - 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", () => { - state.getMessageStore(123)!.setMessageState({ - type: MessageType.Broadcast, - channelId: broadcastChannel, - messageId: broadcastMessage1.messageId, - newState: MessageState.Ack, - }); - const message = useMessageStore - .getState() - .getMessageStore(123)! - .messages.broadcast.get(broadcastChannel) - ?.get(broadcastMessage1.messageId); - expect(message?.state).toBe(MessageState.Ack); - }); - - it("should warn if message is not found (direct)", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - state.getMessageStore(123)!.setMessageState({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum1, - messageId: 999, - newState: MessageState.Ack, - }); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Message or conversation/channel not found for state update"), - ); - warnSpy.mockRestore(); - }); - - it("should warn if message is not found (broadcast)", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - state.getMessageStore(123)!.setMessageState({ - type: MessageType.Broadcast, - channelId: broadcastChannel, - messageId: 999, - newState: MessageState.Ack, - }); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Message or conversation/channel not found for state update"), - ); - warnSpy.mockRestore(); - }); - - it("should warn if conversation/channel is not found", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - state.getMessageStore(123)!.setMessageState({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: 998, - messageId: 101, - newState: MessageState.Ack, - }); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Message or conversation/channel not found for state update"), - ); - warnSpy.mockRestore(); - }); - }); - - describe("clearMessageByMessageId", async () => { - const { useMessageStore } = await freshStore(); - const state = useMessageStore.getState(); - state.addMessageStore(123); - - const extraDirectMessageId = 1011; - beforeEach(() => { - 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, - }); - }); - - it("should delete a specific direct message", () => { - const messageIdToDelete = directMessageToOther1.messageId; - const nodeA = directMessageToOther1.from; - const nodeB = directMessageToOther1.to; - const conversationId = getConversationId(nodeA, nodeB); - - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: nodeA, - nodeB: nodeB, - messageId: messageIdToDelete, - }); - - 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(store.messages.direct.has(conversationId)).toBe(true); - }); - - it("should delete another specific direct message", () => { - const messageIdToDelete = directMessageFromOther1.messageId; - const nodeA = directMessageFromOther1.from; - const nodeB = directMessageFromOther1.to; - const conversationId = getConversationId(nodeA, nodeB); - - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: nodeA, - nodeB: nodeB, - messageId: messageIdToDelete, - }); - - 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); - }); - - it("should delete a specific broadcast message", () => { - const messageIdToDelete = broadcastMessage1.messageId; - const channelId = broadcastMessage1.channel; - - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Broadcast, - channelId: channelId, - messageId: messageIdToDelete, - }); - - const store = useMessageStore.getState().getMessageStore(123)!; - expect(store.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined(); - }); - - it("should clean up empty conversation/channel Maps", () => { - const directConvId = getConversationId( - directMessageFromOther1.from, - directMessageFromOther1.to, - ); - const broadcastChanId = broadcastMessage1.channel; - - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: directMessageToOther1.from, - nodeB: directMessageToOther1.to, - messageId: directMessageToOther1.messageId, - }); - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: directMessageFromOther1.from, - nodeB: directMessageFromOther1.to, - messageId: directMessageFromOther1.messageId, - }); - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: directMessageToOther1.from, - nodeB: directMessageToOther1.to, - messageId: extraDirectMessageId, - }); - - expect(state.getMessageStore(123)?.messages.direct.has(directConvId)).toBe(false); - - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Broadcast, - channelId: broadcastChanId, - messageId: broadcastMessage1.messageId, - }); - - expect(state.getMessageStore(123)?.messages.broadcast.has(broadcastChanId)).toBe(false); - }); - - it("should not error when trying to delete non-existent message", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const conversationId = getConversationId(myNodeNum, otherNodeNum1); - - expect(() => { - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum1, - messageId: 9999, - }); - }).not.toThrow(); - - 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")); - - warnSpy.mockRestore(); - }); - - it("should not error when trying to delete from non-existent conversation/channel", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(() => { - state.getMessageStore(123)?.clearMessageByMessageId({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: 9998, - messageId: 101, - }); - }).not.toThrow(); - - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry")); - - expect(warnSpy).toHaveBeenCalledTimes(1); - - warnSpy.mockRestore(); - }); - }); - - 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", () => { - state.getMessageStore(123)?.setDraft(draftKeyDirect, draftMessage); - expect(state.getMessageStore(123)?.drafts.get(draftKeyDirect)).toBe(draftMessage); - expect(state.getMessageStore(123)?.getDraft(draftKeyDirect)).toBe(draftMessage); - }); - - it("should set and get a draft for broadcast chat", () => { - state.getMessageStore(123)?.setDraft(draftKeyBroadcast, draftMessage); - expect(state.getMessageStore(123)?.drafts.get(draftKeyBroadcast)).toBe(draftMessage); - expect(state.getMessageStore(123)?.getDraft(draftKeyBroadcast)).toBe(draftMessage); - }); - - it("should return empty string for non-existent draft", () => { - expect(state.getMessageStore(123)?.getDraft(999)).toBe(""); - }); - - it("should clear a draft", () => { - 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", async () => { - const { useMessageStore } = await freshStore(); - const state = useMessageStore.getState(); - state.addMessageStore(123); - - it("should clear all direct and broadcast messages, leaving empty Maps", () => { - state.getMessageStore(123)?.saveMessage(directMessageToOther1); - state.getMessageStore(123)?.saveMessage(broadcastMessage1); - - expect(state.getMessageStore(123)?.messages.direct.size).toBeGreaterThan(0); - expect(state.getMessageStore(123)?.messages.broadcast.size).toBeGreaterThan(0); - - state.getMessageStore(123)?.deleteAllMessages(); - - const store = useMessageStore.getState().getMessageStore(123)!; - expect(store.messages.direct).toBeInstanceOf(Map); - expect(store.messages.direct.size).toBe(0); - expect(store.messages.broadcast).toBeInstanceOf(Map); - expect(store.messages.broadcast.size).toBe(0); - }); - }); - - describe("persistence", () => { - it("partialize persists data; onRehydrateStorage rebuilds methods (messages + drafts survive)", async () => { - { - const { useMessageStore } = await freshStore(true); - const state = useMessageStore.getState(); - - const store = state.addMessageStore(123); - store.setNodeNum(321); - - const convId = getConversationId(myNodeNum, otherNodeNum1); - store.saveMessage(directMessageToOther1); - store.saveMessage(broadcastMessage1); - store.setDraft(otherNodeNum1 as unknown as Types.Destination, "draft-text"); - - const store2 = state.addMessageStore(123); - - expect(store2.messages.direct.has(convId)).toBe(true); - expect(store2.messages.direct.get(convId)?.has(101)).toBe(true); - expect(store2.messages.broadcast.get(broadcastChannel)?.has(201)).toBe(true); - expect(store2.getDraft(otherNodeNum1 as unknown as Types.Destination)).toBe("draft-text"); - } - { - const { useMessageStore } = await freshStore(true); - const state = useMessageStore.getState(); - - const store = state.getMessageStore(123)!; // rebuilt instance - expect(store).toBeTruthy(); - - // Methods should work after rehydrate - const directMsgs = store.getMessages({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum1, - }); - expect(directMsgs.map((m) => m.messageId)).toEqual([101]); - - const bMsgs = store.getMessages({ - type: MessageType.Broadcast, - channelId: broadcastChannel, - }); - expect(bMsgs.map((m) => m.messageId)).toEqual([201]); - - expect(store.getDraft(otherNodeNum1 as unknown as Types.Destination)).toBe("draft-text"); - - store.saveMessage(directMessageFromOther1); - - const after = store.getMessages({ - type: MessageType.Direct, - nodeA: myNodeNum, - nodeB: otherNodeNum1, - }); - expect(after.map((m) => m.messageId)).toEqual([101, 102]); - expect(after[1]?.state).toBe(MessageState.Waiting); - } - }); - - it("removeMessageStore persists removal across reload", async () => { - { - const { useMessageStore } = await freshStore(true); - const state = useMessageStore.getState(); - const store = state.addMessageStore(99); - - store.setNodeNum(42); - expect(state.getMessageStore(99)).toBeDefined(); - - state.removeMessageStore(99); - expect(state.getMessageStore(99)).toBeUndefined(); - } - { - const { useMessageStore } = await freshStore(true); - const state = useMessageStore.getState(); - expect(state.getMessageStore(99)).toBeUndefined(); // still gone - } - }); - - it("rehydrate only rebuilds stores with myNodeNum set (orphans dropped)", async () => { - { - const { useMessageStore } = await freshStore(true); - const state = useMessageStore.getState(); - - // Orphan (no myNodeNum) - const orphan = state.addMessageStore(500); - orphan.saveMessage(broadcastMessage1); - - // Proper store - const good = state.addMessageStore(501); - good.setNodeNum(777); - good.saveMessage(broadcastMessage2); - } - { - const { useMessageStore } = await freshStore(true); - const state = useMessageStore.getState(); - - expect(state.getMessageStore(500)).toBeUndefined(); // orphan dropped - const kept = state.getMessageStore(501); - expect(kept).toBeDefined(); - - expect(kept?.messages.broadcast.get(broadcastChannel)?.has(202)).toBe(true); - } - }); - - it("evicts the earliest-added message store when exceeding cap of 10", async () => { - const { useMessageStore } = await freshStore(); - const state = useMessageStore.getState(); - - for (let i = 1; i <= 10; i++) { - state.addMessageStore(i); - } - // Adding the 11th should evict the earliest (id=1) - state.addMessageStore(11); - - expect(state.getMessageStore(1)).toBeUndefined(); // evicted - expect(state.getMessageStore(2)).toBeDefined(); // still there - expect(state.getMessageStore(11)).toBeDefined(); // newest kept - }); - - it("keeps only the latest 1000 messages in a broadcast channel (oldest trimmed)", async () => { - setAutoFreeze(false); // Disable immer auto-freeze for performance in this test - try { - const { useMessageStore, MessageType, MessageState } = await freshStore(); - const state = useMessageStore.getState(); - - const store = state.addMessageStore(123); - - const channelId = 0 as number; - const total = 1005; - - for (let i = 1; i <= total; i++) { - store.saveMessage({ - type: MessageType.Broadcast, - from: 123, - to: 0xffffffff, - channel: channelId, - date: Date.now() + i, - messageId: i, - state: MessageState.Waiting, - message: `m${i}`, - }); - } - - const fresh = useMessageStore.getState().getMessageStore(123)!; - const log = fresh.messages.broadcast.get(channelId)!; - - expect(log.size).toBe(1000); // capped - for (let i = 1; i <= 5; i++) { - expect(log.has(i)).toBe(false); // oldest removed - } - for (let i = 6; i <= 1005; i++) { - expect(log.has(i)).toBe(true); // newest kept - } - } finally { - setAutoFreeze(true); // Restore immer auto-freeze - } - }); - }); -}); diff --git a/packages/web/src/core/stores/messageStore/types.ts b/packages/web/src/core/stores/messageStore/types.ts deleted file mode 100644 index 120e70b4..00000000 --- a/packages/web/src/core/stores/messageStore/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { MessageState, MessageType } from "@core/stores"; -import type { Types } from "@meshtastic/sdk"; - -type NodeNum = number; -type MessageId = number; -type ChannelId = Types.ChannelNumber; -type ConversationId = string; -type MessageLogMap = Map; - -interface MessageBase { - channel: Types.ChannelNumber; - to: number; - from: number; - date: number; - messageId: number; - state: MessageState; - message: string; -} - -interface GenericMessage extends MessageBase { - type: T; -} - -type Message = GenericMessage | GenericMessage; - -type GetMessagesParams = - | { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum } - | { type: MessageType.Broadcast; channelId: ChannelId }; - -type SetMessageStateParams = - | { - type: MessageType.Direct; - nodeA: NodeNum; - nodeB: NodeNum; - messageId: MessageId; // ID of the message within that chat - newState?: MessageState; // Optional new state, defaults to Ack - } - | { - type: MessageType.Broadcast; - channelId: ChannelId; - messageId: MessageId; - newState?: MessageState; // Optional new state, defaults to Ack - }; - -type ClearMessageParams = - | { - type: MessageType.Direct; - nodeA: NodeNum; - nodeB: NodeNum; - messageId: MessageId; - } - | { - type: MessageType.Broadcast; - channelId: ChannelId; - messageId: MessageId; - }; - -export type { - ChannelId, - ClearMessageParams, - ConversationId, - GetMessagesParams, - Message, - MessageId, - MessageLogMap, - NodeNum, - SetMessageStateParams, -}; diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 56a129d9..245214f7 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -1,5 +1,5 @@ import { useNewNodeNum } from "@core/hooks/useNewNodeNum"; -import { type Device, type MessageStore } from "@core/stores"; +import { type Device } from "@core/stores"; import { type MeshDevice, Protobuf } from "@meshtastic/sdk"; /** @@ -12,12 +12,7 @@ import { type MeshDevice, Protobuf } from "@meshtastic/sdk"; * device-store-only state (waypoints, traceroutes, neighbour info, dialog * open triggers, unread counts). */ -export const subscribeAll = ( - device: Device, - connection: MeshDevice, - // biome-ignore lint/correctness/noUnusedFunctionParameters: kept for callsite stability while messageStore is being retired - _messageStore: MessageStore, -) => { +export const subscribeAll = (device: Device, connection: MeshDevice) => { let myNodeNum = 0; connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => { diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index 058f1f0a..e296ab40 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -10,7 +10,7 @@ import { createRoot } from "react-dom/client"; import "./i18n-config.ts"; import { router } from "@app/routes.tsx"; import { meshRegistry } from "@core/meshRegistry.ts"; -import { useAppStore, useMessageStore } from "@core/stores"; +import { useAppStore } from "@core/stores"; import { MeshRegistryProvider } from "@meshtastic/sdk-react"; import { type createRouter, RouterProvider } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; @@ -26,18 +26,16 @@ const root = createRoot(container); function IndexPage() { enableMapSet(); const appStore = useAppStore(); - const messageStore = useMessageStore(); const translation = useTranslation(); const context = React.useMemo( () => ({ stores: { app: appStore, - message: messageStore, }, i18n: translation, }), - [appStore, messageStore, translation], + [appStore, translation], ); return ( diff --git a/packages/web/src/pages/Connections/useConnections.ts b/packages/web/src/pages/Connections/useConnections.ts index d818e45f..8e443f46 100644 --- a/packages/web/src/pages/Connections/useConnections.ts +++ b/packages/web/src/pages/Connections/useConnections.ts @@ -7,7 +7,7 @@ import type { import { createConnectionFromInput, testHttpReachable } from "@app/pages/Connections/utils"; import { meshRegistry } from "@core/meshRegistry.ts"; import { coordinator, getStorageDb } from "@core/sdkStorage.ts"; -import { useAppStore, useDeviceStore, useMessageStore } from "@core/stores"; +import { useAppStore, useDeviceStore } from "@core/stores"; import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/sdk"; @@ -40,7 +40,6 @@ export function useConnections() { const setActiveConnectionId = useDeviceStore((s) => s.setActiveConnectionId); const { addDevice } = useDeviceStore(); - const { addMessageStore } = useMessageStore(); const { setSelectedDevice } = useAppStore(); const selectedDeviceId = useAppStore((s) => s.selectedDeviceId); @@ -153,7 +152,6 @@ export function useConnections() { deviceId = deviceId ?? randId(); const device = addDevice(deviceId); - const messageStore = addMessageStore(deviceId); // Wire the SDK slices to the OPFS-backed SQLite repositories so the // user keeps message + draft + node history across reloads. The DB is @@ -192,7 +190,7 @@ export function useConnections() { setSelectedDevice(deviceId); device.addConnection(meshDevice); // This stores meshDevice in Device.connection - subscribeAll(device, meshDevice, messageStore); + subscribeAll(device, meshDevice); // Store transport locally for cleanup (BT/Serial only) if (btDevice || serialPort) { @@ -266,7 +264,6 @@ export function useConnections() { [ connections, addDevice, - addMessageStore, setSelectedDevice, setActiveConnectionId, updateSavedConnection, diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx index 57a93f8a..542b6df6 100644 --- a/packages/web/src/routes.tsx +++ b/packages/web/src/routes.tsx @@ -1,5 +1,5 @@ import { DialogManager } from "@components/Dialog/DialogManager.tsx"; -import type { useAppStore, useMessageStore } from "@core/stores"; +import type { useAppStore } from "@core/stores"; import { Connections } from "@pages/Connections/index.tsx"; import MapPage from "@pages/Map/index.tsx"; import MessagesPage from "@pages/Messages.tsx"; @@ -18,7 +18,6 @@ import { App } from "./App.tsx"; interface AppContext { stores: { app: ReturnType; - message: ReturnType; }; i18n: ReturnType; } @@ -164,7 +163,6 @@ const router = createRouter({ context: { stores: { app: {} as ReturnType, - message: {} as ReturnType, }, i18n: {} as ReturnType, },