mirror of
https://github.com/meshtastic/web.git
synced 2026-07-31 15:06:27 -04:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = "/";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user