chore(web): retire Zustand messageStore — keep only Message types/enums

The persisted message Zustand store had no remaining consumers — chat
history, drafts, and message state all live on the SDK ChatClient
(SqlocalMessageRepository). The messageStore Zustand surface
(saveMessage, getMessages, setMessageState, getDraft, setDraft,
clearDraft, deleteAllMessages, clearMessageByMessageId, plus the
addMessageStore / removeMessageStore / getMessageStore / setNodeNum
plumbing) is now fully unused.

Collapse messageStore/index.ts to just the MessageState / MessageType
enums + the legacy `Message` shape that the useChatLegacy adapter and a
couple of message components still consume. Delete the Zustand
implementation and its 32-test suite.

Other knock-on cleanups in this commit:
- Drop the _messageStore param from subscribeAll (unused after the
  saveMessage-from-subscriptions retirement).
- Drop addMessageStore wiring from useConnections, removeMessageStore
  from FactoryResetDeviceDialog, setNodeNum from useNewNodeNum.
- Drop the message branch from the router context (no readers).
- RefreshKeysDialog stops keying off `useMessages().activeChat` (which
  was permanently 0 — a dead handle that quietly broke key-refresh
  UX). Use the SDK NodesClient's first error directly via
  `useNodeErrors()[0]`. The dialog manager already opens the dialog on
  PKI_UNKNOWN_PUBKEY, so picking the first error matches the intended
  flow.
This commit is contained in:
Dan Ditomaso
2026-04-25 22:28:28 -04:00
parent f29b2ba93a
commit 0342eca505
17 changed files with 58 additions and 1288 deletions

View File

@@ -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(<FactoryResetDeviceDialog open onOpenChange={mockOnOpenChange} />);
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();
});
});

View File

@@ -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 = "/";

View File

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

View File

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

View File

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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

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

View File

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

View File

@@ -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<T extends MessageType> extends MessageBase {
type: T;
}
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
return [node1, node2].sort((a, b) => a - b).join(":");
}
export interface MessageBuckets {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
}
type MessageStoreData = {
// Persisted data
id: number;
myNodeNum: number | undefined;
messages: MessageBuckets;
drafts: Map<Types.Destination, string>;
};
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<number, MessageStore>;
}
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,
setNodeNum: (nodeNum) => {
set(
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);
}
}),
);
},
saveMessage: (message: Message) => {
set(
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)) {
state.messages.direct.set(conversationId, new Map<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)) {
state.messages.broadcast.set(channelId, new Map<MessageId, Message>());
}
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<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;
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<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<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;
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<PrivateMessageStoreState> = (set, get) => ({
messageStores: new Map(),
addMessageStore: (id) => {
const existing = get().messageStores.get(id);
if (existing) {
return existing;
}
const nodeStore = messageStoreFactory(id, get, set);
set(
produce<PrivateMessageStoreState>((draft) => {
draft.messageStores.set(id, nodeStore);
// Enforce retention limit
evictOldestEntries(draft.messageStores, MESSAGESTORE_RETENTION_NUM);
}),
);
return nodeStore;
},
removeMessageStore: (id) => {
set(
produce<PrivateMessageStoreState>((draft) => {
draft.messageStores.delete(id);
}),
);
},
getMessageStores: () => Array.from(get().messageStores.values()),
getMessageStore: (id) => get().messageStores.get(id),
});
const persistOptions: PersistOptions<PrivateMessageStoreState, MessageStorePersisted> = {
name: IDB_KEY_NAME,
storage: createStorage<MessageStorePersisted>(),
version: CURRENT_STORE_VERSION,
partialize: (s): MessageStorePersisted => ({
messageStores: new Map(
Array.from(s.messageStores.entries()).map(([id, db]) => [
id,
{
id: db.id,
myNodeNum: db.myNodeNum,
messages: db.messages,
drafts: db.drafts,
},
]),
),
}),
onRehydrateStorage: () => (state) => {
if (!state) {
return;
}
console.debug(
"MessageStoreStore: Rehydrating state with ",
state.messageStores.size,
" MessageStores -",
state.messageStores,
);
useMessageStore.setState(
produce<PrivateMessageStoreState>((draft) => {
const rebuilt = new Map<number, MessageStore>();
for (const [id, data] of (
draft.messageStores as unknown as Map<number, MessageStoreData>
).entries()) {
if (data.myNodeNum !== undefined) {
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
rebuilt.set(
id,
messageStoreFactory(id, useMessageStore.getState, useMessageStore.setState, data),
);
}
}
draft.messageStores = rebuilt;
}),
);
},
};
// 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);

View File

@@ -1,727 +0,0 @@
/** biome-ignore-all lint/style/noNonNullAssertion: <tests> */
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<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))),
set: vi.fn((key: string, val: string) => {
idbMem.set(key, val);
return Promise.resolve();
}),
del: vi.fn((k: string) => {
idbMem.delete(k);
return Promise.resolve();
}),
}));
async function freshStore(persist = false) {
vi.resetModules();
// suppress console output from the store during tests (for github actions)
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
// Mock feature flag for persistence
vi.doMock("@core/services/featureFlags", () => ({
featureFlags: {
get: vi.fn((key: string) => (key === "persistMessages" ? persist : false)),
},
}));
const mod = await import("./index.ts");
return mod;
}
const myNodeNum = 111;
const otherNodeNum1 = 222;
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
}
});
});
});

View File

@@ -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<MessageId, Message>;
interface MessageBase {
channel: Types.ChannelNumber;
to: number;
from: number;
date: number;
messageId: number;
state: MessageState;
message: string;
}
interface GenericMessage<T extends MessageType> extends MessageBase {
type: T;
}
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
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,
};

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -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<typeof useAppStore>;
message: ReturnType<typeof useMessageStore>;
};
i18n: ReturnType<typeof useTranslation>;
}
@@ -164,7 +163,6 @@ const router = createRouter({
context: {
stores: {
app: {} as ReturnType<typeof useAppStore>,
message: {} as ReturnType<typeof useMessageStore>,
},
i18n: {} as ReturnType<typeof import("react-i18next").useTranslation>,
},