+
+
+
+
+
-
),
});
- return () => {
- if (!toastShownRef.current) {
- dismiss();
- }
- };
+ return () => dismiss();
}, [
enabled,
message,
onAccept,
- reminderInDays,
- suppressReminder,
- toast,
- reminderCookie,
+
]);
-}
+};
\ No newline at end of file
diff --git a/src/core/hooks/useLocalStorage.test.ts b/src/core/hooks/useLocalStorage.test.ts
new file mode 100644
index 00000000..8d621a57
--- /dev/null
+++ b/src/core/hooks/useLocalStorage.test.ts
@@ -0,0 +1,52 @@
+import { renderHook, act } from '@testing-library/react'
+import useLocalStorage from './useLocalStorage'
+import { beforeEach, describe, expect, it } from "vitest";
+
+describe('useLocalStorage', () => {
+ const key = 'test-key'
+
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('should initialize with initial value if localStorage is empty', () => {
+ const { result } = renderHook(() => useLocalStorage(key, 'initial'))
+ const [value] = result.current
+ expect(value).toBe('initial')
+ })
+
+ it('should read existing value from localStorage', () => {
+ localStorage.setItem(key, JSON.stringify('stored'))
+ const { result } = renderHook(() => useLocalStorage(key, 'initial'))
+ const [value] = result.current
+ expect(value).toBe('stored')
+ })
+
+ it('should update localStorage when setValue is called', () => {
+ const { result } = renderHook(() => useLocalStorage(key, 'initial'))
+ const [, setValue] = result.current
+
+ act(() => {
+ setValue('updated')
+ })
+
+ expect(localStorage.getItem(key)).toBe(JSON.stringify('updated'))
+ expect(result.current[0]).toBe('updated')
+ })
+
+ it('should remove value from localStorage when removeValue is called', () => {
+ const { result } = renderHook(() => useLocalStorage(key, 'initial'))
+ const [, setValue, removeValue] = result.current
+
+ act(() => {
+ setValue('to-be-removed')
+ })
+
+ act(() => {
+ removeValue()
+ })
+
+ expect(localStorage.getItem(key)).toBeNull()
+ expect(result.current[0]).toBe('initial')
+ })
+})
diff --git a/src/core/hooks/usePinnedItems.test.ts b/src/core/hooks/usePinnedItems.test.ts
new file mode 100644
index 00000000..d761fba1
--- /dev/null
+++ b/src/core/hooks/usePinnedItems.test.ts
@@ -0,0 +1,65 @@
+import { renderHook, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { usePinnedItems } from "./usePinnedItems.ts";
+
+const mockSetPinnedItems = vi.fn();
+const mockUseLocalStorage = vi.fn();
+
+vi.mock("@core/hooks/useLocalStorage.ts", () => ({
+ default: (...args: any[]) => mockUseLocalStorage(...args),
+}));
+
+describe("usePinnedItems", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns default pinnedItems and togglePinnedItem", () => {
+ mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]);
+
+ const { result } = renderHook(() =>
+ usePinnedItems({ storageName: "test-storage" })
+ );
+
+ expect(result.current.pinnedItems).toEqual([]);
+ expect(typeof result.current.togglePinnedItem).toBe("function");
+ });
+
+ it("adds an item if it's not already pinned", () => {
+ mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]);
+
+ const { result } = renderHook(() =>
+ usePinnedItems({ storageName: "test-storage" })
+ );
+
+ act(() => {
+ result.current.togglePinnedItem("item2");
+ });
+
+ expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
+
+ const updater = mockSetPinnedItems.mock.calls[0][0];
+ const updated = updater(["item1"]);
+
+ expect(updated).toEqual(["item1", "item2"]);
+ });
+
+ it("removes an item if it's already pinned", () => {
+ mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]);
+
+ const { result } = renderHook(() =>
+ usePinnedItems({ storageName: "test-storage" })
+ );
+
+ act(() => {
+ result.current.togglePinnedItem("item1");
+ });
+
+ expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
+
+ const updater = mockSetPinnedItems.mock.calls[0][0];
+ const updated = updater(["item1", "item2"]);
+
+ expect(updated).toEqual(["item2"]);
+ });
+});
diff --git a/src/core/hooks/usePinnedItems.ts b/src/core/hooks/usePinnedItems.ts
new file mode 100644
index 00000000..cd80a17c
--- /dev/null
+++ b/src/core/hooks/usePinnedItems.ts
@@ -0,0 +1,19 @@
+import useLocalStorage from "@core/hooks/useLocalStorage.ts";
+import { useCallback } from "react";
+
+export function usePinnedItems({ storageName }: { storageName: string }) {
+ const [pinnedItems, setPinnedItems] = useLocalStorage
(storageName, []);
+
+ const togglePinnedItem = useCallback((label: string) => {
+ setPinnedItems((prev) =>
+ prev.includes(label)
+ ? prev.filter((g) => g !== label)
+ : [...prev, label]
+ );
+ }, []);
+
+ return {
+ pinnedItems,
+ togglePinnedItem,
+ };
+}
diff --git a/src/core/hooks/useToast.test.tsx b/src/core/hooks/useToast.test.tsx
new file mode 100644
index 00000000..9125da75
--- /dev/null
+++ b/src/core/hooks/useToast.test.tsx
@@ -0,0 +1,81 @@
+import { renderHook, act } from '@testing-library/react'
+import { useToast } from "@core/hooks/useToast.ts"
+import { Button } from '@components/UI/Button.tsx'
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+describe('useToast', () => {
+ beforeEach(() => {
+ // Reset toast memory state before each test
+ // our hook uses global memory to store toasts
+ // @ts-expect-error - internal test reset
+ globalThis.memoryState = { toasts: [] }
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should create a toast with title, description, and action', () => {
+ const { result } = renderHook(() => useToast())
+
+ act(() => {
+ result.current.toast({
+ title: 'Backup Reminder',
+ description: 'Don\'t forget to backup!',
+ action:
+ })
+ vi.runAllTimers()
+ })
+
+ const toast = result.current.toasts[0]
+ expect(result.current.toasts.length).toBe(1)
+ expect(toast.title).toBe('Backup Reminder')
+ expect(toast.description).toBe('Don\'t forget to backup!')
+ expect(toast.action).toBeTruthy()
+ expect(toast.open).toBe(true)
+ })
+ it('should dismiss a toast using returned dismiss function', () => {
+ const { result } = renderHook(() => useToast())
+ vi.useFakeTimers()
+
+ let toastRef: { id: string, dismiss: () => void }
+
+ act(() => {
+ toastRef = result.current.toast({ title: 'Dismiss Me' })
+ vi.runAllTimers() // Flush ADD_TOAST
+ })
+
+ act(() => {
+ toastRef.dismiss()
+ })
+
+ const toast = result.current.toasts.find(t => t.id === toastRef.id)
+ expect(toast?.open).toBe(false)
+
+ vi.useRealTimers()
+ })
+
+
+ it('should allow dismiss via hook dismiss function', () => {
+ const { result } = renderHook(() => useToast())
+ vi.useFakeTimers()
+
+ let toastRef: { id: string }
+
+ act(() => {
+ toastRef = result.current.toast({ title: 'Manual Dismiss' })
+ vi.runAllTimers()
+ })
+
+ act(() => {
+ result.current.dismiss(toastRef.id)
+ })
+
+ const toast = result.current.toasts.find(t => t.id === toastRef.id)
+ expect(toast?.open).toBe(false)
+
+ vi.useRealTimers()
+ })
+
+})
diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts
index 3269eee9..d728537f 100644
--- a/src/core/hooks/useToast.ts
+++ b/src/core/hooks/useToast.ts
@@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) {
...props,
id,
open: true,
- onOpenChange: (open) => {
+ onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
diff --git a/src/core/stores/appStore.ts b/src/core/stores/appStore.ts
index 42143426..6b3ab022 100644
--- a/src/core/stores/appStore.ts
+++ b/src/core/stores/appStore.ts
@@ -1,4 +1,3 @@
-import { Types } from "@meshtastic/core";
import { produce } from "immer";
import { create } from "zustand";
@@ -31,14 +30,11 @@ export interface AppState {
id: number;
num: number;
}[];
-
rasterSources: RasterSource[];
commandPaletteOpen: boolean;
nodeNumToBeRemoved: number;
connectDialogOpen: boolean;
nodeNumDetails: number;
- activeChat: number;
- chatType: "broadcast" | "direct";
errors: ErrorState[];
unreadCounts: Map;
@@ -52,8 +48,6 @@ export interface AppState {
setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
- setActiveChat: (chat: number) => void;
- setChatType: (type: "broadcast" | "direct") => void;
// Error management
hasErrors: () => boolean;
@@ -77,8 +71,6 @@ export const useAppStore = create()((set, get) => ({
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
- activeChat: Types.ChannelNumber.Primary,
- chatType: "broadcast",
errors: [],
unreadCounts: new Map([[0, 100],[2718471552, 1]]),
@@ -138,14 +130,6 @@ export const useAppStore = create()((set, get) => ({
set(() => ({
nodeNumDetails: nodeNum,
})),
- setActiveChat: (chat) =>
- set(() => ({
- activeChat: chat,
- })),
- setChatType: (type) =>
- set(() => ({
- chatType: type,
- })),
hasErrors: () => {
const state = get();
return state.errors.length > 0;
diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts
index 90aad765..2912f9da 100644
--- a/src/core/stores/deviceStore.ts
+++ b/src/core/stores/deviceStore.ts
@@ -10,7 +10,7 @@ export interface MessageWithState extends Types.PacketMetadata {
state: MessageState;
}
-export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error;
+export type MessageState = "ack" | "waiting" | 'failed';
export interface ProcessPacketParams {
from: number;
@@ -23,16 +23,14 @@ export type DialogVariant =
| "QR"
| "shutdown"
| "reboot"
+ | "rebootOTA"
| "deviceName"
| "nodeRemoval"
| "pkiBackup"
| "nodeDetails"
| "unsafeRoles"
- | "refreshKeys";
-
-type QueueStatus = {
- res: number, free: number, maxlen: number
-}
+ | "refreshKeys"
+ | "clearMessages";
type NodeError = {
node: number;
@@ -50,10 +48,6 @@ export interface Device {
hardware: Protobuf.Mesh.MyNodeInfo;
nodes: Map;
metadata: Map;
- messages: {
- direct: Map;
- broadcast: Map;
- };
traceroutes: Map<
number,
Types.PacketMetadata[]
@@ -66,19 +60,19 @@ export interface Device {
// currentMetrics: Protobuf.DeviceMetrics;
pendingSettingsChanges: boolean;
messageDraft: string;
- queueStatus: QueueStatus,
- isQueueingMessages: boolean,
dialog: {
import: boolean;
QR: boolean;
shutdown: boolean;
reboot: boolean;
+ rebootOTA: boolean;
deviceName: boolean;
nodeRemoval: boolean;
pkiBackup: boolean;
nodeDetails: boolean;
unsafeRoles: boolean;
refreshKeys: boolean;
+ clearMessages: boolean;
};
unreadCounts: Map;
@@ -99,26 +93,16 @@ export interface Device {
addUser: (user: Types.PacketMetadata) => void;
addPosition: (position: Types.PacketMetadata) => void;
addConnection: (connection: MeshDevice) => void;
- addMessage: (message: MessageWithState) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata,
) => void;
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
removeNode: (nodeNum: number) => void;
- setMessageState: (
- type: "direct" | "broadcast",
- channelIndex: Types.ChannelNumber,
- to: number,
- from: number,
- messageId: number,
- state: MessageState,
- ) => void;
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
getDialogOpen: (dialog: DialogVariant) => boolean;
processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void;
setUnread: (id: number, count: number) => void;
- setQueueStatus: (status: QueueStatus) => void;
setNodeError: (nodeNum: number, error: string) => void;
clearNodeError: (nodeNum: number) => void;
getNodeError: (nodeNum: number) => NodeError | undefined;
@@ -153,19 +137,11 @@ export const useDeviceStore = createStore((set, get) => ({
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
nodes: new Map(),
metadata: new Map(),
- messages: {
- direct: new Map(),
- broadcast: new Map(),
- },
traceroutes: new Map(),
connection: undefined,
activePage: "messages",
activeNode: 0,
waypoints: [],
- queueStatus: {
- res: 0, free: 0, maxlen: 0
- },
- isQueueingMessages: false,
dialog: {
import: false,
QR: false,
@@ -177,6 +153,7 @@ export const useDeviceStore = createStore((set, get) => ({
nodeDetails: false,
unsafeRoles: false,
refreshKeys: false,
+ rebootOTA: false,
},
pendingSettingsChanges: false,
messageDraft: "",
@@ -509,31 +486,6 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
- addMessage: (message) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const messageGroup = device.messages[message.type];
- const messageIndex = message.type === "direct"
- ? message.from === device.hardware.myNodeNum
- ? message.to
- : message.from
- : message.channel;
- const messages = messageGroup.get(messageIndex);
-
- if (messages) {
- messages.push(message);
- messageGroup.set(messageIndex, messages);
- } else {
- messageGroup.set(messageIndex, [message]);
- }
- }),
- );
- },
-
addMetadata: (from, metadata) => {
set(
produce((draft) => {
@@ -574,43 +526,6 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
- setMessageState: (
- type: "direct" | "broadcast",
- channelIndex: Types.ChannelNumber,
- to: number,
- from: number,
- messageId: number,
- state: MessageState,
- ) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (!device) {
- return;
- }
- const messageGroup = device.messages[type];
-
- const messageIndex = type === "direct"
- ? from === device.hardware.myNodeNum ? to : from
- : channelIndex;
- const messages = messageGroup.get(messageIndex);
-
- if (!messages) {
- return;
- }
-
- messageGroup.set(
- messageIndex,
- messages.map((msg) => {
- if (msg.id === messageId) {
- msg.state = state;
- }
- return msg;
- }),
- );
- }),
- );
- },
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
produce((draft) => {
@@ -680,17 +595,6 @@ export const useDeviceStore = createStore((set, get) => ({
})
);
},
- setQueueStatus: (status: QueueStatus) => {
- set(
- produce((draft) => {
- const device = draft.devices.get(id);
- if (device) {
- device.queueStatus = status;
- device.queueStatus.free >= 10 ? true : false
- }
- }),
- );
- },
setNodeError: (nodeNum, error) => {
set(
produce((draft) => {
diff --git a/src/core/stores/messageStore.test.ts b/src/core/stores/messageStore.test.ts
new file mode 100644
index 00000000..e8fc70c7
--- /dev/null
+++ b/src/core/stores/messageStore.test.ts
@@ -0,0 +1,372 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import {
+ useMessageStore,
+ MessageType,
+ MessageState,
+ type Message,
+} from './messageStore.ts';
+
+let memoryStorage: Record = {};
+
+vi.mock('./storage/indexDB.ts', () => {
+ console.log("Mocking zustandIndexDBStorage...");
+ return {
+ zustandIndexDBStorage: {
+ getItem: vi.fn(async (name: string): Promise => {
+ console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null);
+ return memoryStorage[name] ?? null;
+ }),
+ setItem: vi.fn(async (name: string, value: string): Promise => {
+ console.log(`Mock setItem: ${name}`, value);
+ memoryStorage[name] = value;
+ }),
+ removeItem: vi.fn(async (name: string): Promise => {
+ console.log(`Mock removeItem: ${name}`);
+ delete memoryStorage[name];
+ }),
+ },
+ };
+});
+
+const myNodeNum = 111;
+const otherNodeNum1 = 222;
+const otherNodeNum2 = 333;
+const broadcastChannel = 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('useMessageStore', () => {
+ const initialState = useMessageStore.getState();
+
+ beforeEach(() => {
+ useMessageStore.setState(initialState, true);
+ });
+
+ it('should have correct initial state', () => {
+ const state = useMessageStore.getState();
+ expect(state.messages.direct).toEqual({});
+ expect(state.messages.broadcast).toEqual({});
+ expect(state.draft).toBeInstanceOf(Map);
+ expect(state.draft.size).toBe(0);
+ expect(state.nodeNum).toBe(0);
+ expect(state.activeChat).toBe(0);
+ expect(state.chatType).toBe(MessageType.Broadcast);
+ });
+
+ it('should set nodeNum', () => {
+ useMessageStore.getState().setNodeNum(myNodeNum);
+ expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
+ });
+
+ it('should set activeChat and chatType', () => {
+ useMessageStore.getState().setActiveChat(otherNodeNum1);
+ useMessageStore.getState().setChatType(MessageType.Direct);
+ expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1);
+ expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
+ });
+
+ describe('saveMessage', () => {
+ it('should save a direct message with correct structure', () => {
+ useMessageStore.getState().saveMessage(directMessageToOther1);
+ const state = useMessageStore.getState();
+ expect(state.messages.direct[myNodeNum]).toBeDefined();
+ expect(state.messages.direct[myNodeNum][otherNodeNum1]).toBeDefined();
+ expect(
+ state.messages.direct[myNodeNum][otherNodeNum1][directMessageToOther1.messageId],
+ ).toEqual(directMessageToOther1);
+ });
+
+ it('should save a broadcast message with correct structure', () => {
+ useMessageStore.getState().saveMessage(broadcastMessage1);
+ const state = useMessageStore.getState();
+ expect(state.messages.broadcast[broadcastChannel]).toBeDefined();
+ expect(
+ state.messages.broadcast[broadcastChannel][broadcastMessage1.messageId],
+ ).toEqual(broadcastMessage1);
+ });
+
+ it('should save multiple messages correctly', () => {
+ useMessageStore.getState().saveMessage(directMessageToOther1);
+ useMessageStore.getState().saveMessage(directMessageFromOther1);
+ useMessageStore.getState().saveMessage(broadcastMessage1);
+
+ const state = useMessageStore.getState();
+
+ // Direct msg 1 (me -> other1)
+ expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1);
+ // Direct msg 2 (other1 -> me)
+ expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1);
+ // Broadcast msg 1
+ expect(state.messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]).toEqual(broadcastMessage1);
+ });
+ });
+
+ describe('getMessages', () => {
+ beforeEach(() => {
+ useMessageStore.getState().setNodeNum(myNodeNum);
+ useMessageStore.getState().saveMessage(directMessageToOther1);
+ useMessageStore.getState().saveMessage(directMessageFromOther1);
+ useMessageStore.getState().saveMessage(directMessageToOther2);
+ useMessageStore.getState().saveMessage(broadcastMessage1);
+ useMessageStore.getState().saveMessage(broadcastMessage2);
+ });
+
+ it('should return broadcast messages for a channel, sorted by date', () => {
+ const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, {
+ myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice
+ channel: 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 = useMessageStore.getState().getMessages(MessageType.Broadcast, {
+ myNodeNum: myNodeNum,
+ channel: 99
+ });
+ expect(messages).toEqual([]);
+ });
+
+ it('should return combined direct messages for a specific chat (pair), sorted by date', () => {
+ const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
+ myNodeNum: myNodeNum,
+ otherNodeNum: 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 = useMessageStore.getState().getMessages(MessageType.Direct, {
+ myNodeNum: myNodeNum,
+ otherNodeNum: otherNodeNum2
+ });
+ expect(messages).toHaveLength(1);
+ expect(messages[0]).toEqual(directMessageToOther2);
+ });
+
+ it('should return empty array for direct chat if no messages exist', () => {
+ const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
+ myNodeNum: myNodeNum,
+ otherNodeNum: 999
+ });
+ expect(messages).toEqual([]);
+ });
+
+ it('should return empty array if myNodeNum is not provided for direct messages', () => {
+ const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
+ otherNodeNum: otherNodeNum1
+ });
+ expect(messages).toEqual([]);
+ });
+ });
+
+ describe('setMessageState', () => {
+ beforeEach(() => {
+ useMessageStore.getState().setNodeNum(myNodeNum);
+ useMessageStore.getState().saveMessage(directMessageToOther1);
+ useMessageStore.getState().saveMessage(directMessageFromOther1);
+ useMessageStore.getState().saveMessage(broadcastMessage1);
+ });
+
+ it('should update state for a direct message sent BY ME', () => {
+ useMessageStore.getState().setMessageState({
+ type: MessageType.Direct,
+ key: otherNodeNum1,
+ messageId: directMessageToOther1.messageId,
+ newState: MessageState.Ack,
+ });
+ const message = useMessageStore.getState().messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId];
+ expect(message?.state).toBe(MessageState.Ack);
+ });
+
+ it('should update state for a direct message received FROM OTHER', () => {
+ useMessageStore.getState().setMessageState({
+ type: MessageType.Direct,
+ key: otherNodeNum1,
+ messageId: directMessageFromOther1.messageId,
+ newState: MessageState.Failed,
+ });
+ const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId];
+ expect(message?.state).toBe(MessageState.Failed);
+ });
+
+ it('should update state for a broadcast message', () => {
+ useMessageStore.getState().setMessageState({
+ type: MessageType.Broadcast,
+ key: broadcastChannel,
+ messageId: broadcastMessage1.messageId,
+ newState: MessageState.Ack,
+ });
+ const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId];
+ expect(message?.state).toBe(MessageState.Ack);
+ });
+
+ it('should warn if message is not found', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
+ useMessageStore.getState().setMessageState({
+ type: MessageType.Direct,
+ key: otherNodeNum1,
+ messageId: 999,
+ newState: MessageState.Ack,
+ });
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message not found for state update'));
+ warnSpy.mockRestore();
+ });
+ });
+
+
+ describe('clearMessageByMessageId', () => {
+ beforeEach(() => {
+ useMessageStore.getState().setNodeNum(myNodeNum);
+ useMessageStore.getState().saveMessage(directMessageToOther1);
+ useMessageStore.getState().saveMessage(directMessageFromOther1);
+ useMessageStore.getState().saveMessage(broadcastMessage1);
+ useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: 1011, date: Date.now() + 50 });
+ });
+
+ it('should delete a specific direct message (sent by me)', () => {
+ const messageIdToDelete = directMessageToOther1.messageId;
+ useMessageStore.getState().clearMessageByMessageId({
+ type: MessageType.Direct,
+ sender: myNodeNum,
+ recipient: otherNodeNum1,
+ messageId: messageIdToDelete
+ });
+ const state = useMessageStore.getState();
+ expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[messageIdToDelete]).toBeUndefined();
+ expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
+ expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toBeDefined();
+ });
+
+ it('should delete a specific direct message (sent by other)', () => {
+ const messageIdToDelete = directMessageFromOther1.messageId;
+ useMessageStore.getState().clearMessageByMessageId({
+ type: MessageType.Direct,
+ sender: otherNodeNum1,
+ recipient: myNodeNum,
+ messageId: messageIdToDelete
+ });
+ const state = useMessageStore.getState();
+ expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[messageIdToDelete]).toBeUndefined();
+ expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toBeDefined();
+ expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
+ });
+
+ it('should delete a specific broadcast message', () => {
+ const messageIdToDelete = broadcastMessage1.messageId;
+ useMessageStore.getState().clearMessageByMessageId({
+ type: MessageType.Broadcast,
+ channel: broadcastChannel,
+ messageId: messageIdToDelete
+ });
+ const state = useMessageStore.getState();
+ expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined();
+ });
+
+ it('should clean up empty recipient/sender/channel objects', () => {
+ useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, sender: otherNodeNum1, recipient: myNodeNum, messageId: directMessageFromOther1.messageId });
+ expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed
+ expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed
+
+ useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channel: broadcastChannel, messageId: broadcastMessage1.messageId });
+ expect(useMessageStore.getState().messages.broadcast[broadcastChannel]).toBeUndefined(); // Channel level removed
+ });
+ });
+
+ describe('Drafts', () => {
+ const draftKey = otherNodeNum1;
+ const draftMessage = 'This is a draft';
+
+ it('should set and get a draft', () => {
+ useMessageStore.getState().setDraft(draftKey, draftMessage);
+ expect(useMessageStore.getState().draft.get(draftKey)).toBe(draftMessage);
+ expect(useMessageStore.getState().getDraft(draftKey)).toBe(draftMessage);
+ });
+
+ it('should return empty string for non-existent draft', () => {
+ expect(useMessageStore.getState().getDraft(999)).toBe('');
+ });
+
+ it('should clear a draft', () => {
+ useMessageStore.getState().setDraft(draftKey, draftMessage);
+ expect(useMessageStore.getState().draft.has(draftKey)).toBe(true);
+ useMessageStore.getState().clearDraft(draftKey);
+ expect(useMessageStore.getState().draft.has(draftKey)).toBe(false);
+ expect(useMessageStore.getState().getDraft(draftKey)).toBe('');
+ });
+ });
+
+ describe('clearAllMessages', () => {
+ it('should clear all direct and broadcast messages', () => {
+ useMessageStore.getState().saveMessage(directMessageToOther1);
+ useMessageStore.getState().saveMessage(broadcastMessage1);
+ expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0);
+ expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0);
+
+ useMessageStore.getState().clearAllMessages();
+
+ expect(useMessageStore.getState().messages.direct).toEqual({});
+ expect(useMessageStore.getState().messages.broadcast).toEqual({});
+ });
+ });
+
+});
diff --git a/src/core/stores/messageStore.ts b/src/core/stores/messageStore.ts
new file mode 100644
index 00000000..fdb9ad9c
--- /dev/null
+++ b/src/core/stores/messageStore.ts
@@ -0,0 +1,234 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import { produce } from 'immer';
+import { Types } from '@meshtastic/core';
+import { zustandIndexDBStorage } from "./storage/indexDB.ts";
+
+export enum MessageState {
+ Ack = "ack",
+ Waiting = "waiting",
+ Failed = "failed",
+}
+
+export enum MessageType {
+ Direct = "direct",
+ Broadcast = "broadcast",
+}
+
+interface MessageBase {
+ channel: Types.ChannelNumber;
+ to: number;
+ from: number;
+ date: number;
+ messageId: number;
+ state: MessageState;
+ message: string;
+}
+
+interface GenericMessage extends MessageBase {
+ type: T;
+}
+
+export type Message = GenericMessage | GenericMessage;
+
+export interface MessageStore {
+ messages: {
+ direct: Record>>;
+ broadcast: Record>; // channel -> messageId -> Message
+ };
+ draft: Map;
+ nodeNum: number; // This device's node number
+ activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
+ chatType: MessageType;
+
+ setNodeNum: (nodeNum: number) => void;
+ getNodeNum: () => number;
+ setActiveChat: (chat: number) => void;
+ setChatType: (type: MessageType) => void;
+ saveMessage: (message: Message) => void;
+ setMessageState: (params: {
+ type: MessageType;
+ // For Direct: Represents the *other* node number involved in the chat.
+ // For Broadcast: Represents the channel number.
+ key: number;
+ messageId: number;
+ newState?: MessageState;
+ }) => void;
+ getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[];
+ getDraft: (key: Types.Destination) => string;
+ setDraft: (key: Types.Destination, message: string) => void;
+ clearAllMessages: () => void;
+ clearMessageByMessageId: (params: {
+ type: MessageType;
+ sender?: number;
+ recipient?: number;
+ channel?: number;
+ messageId: number
+ }) => void;
+ clearDraft: (key: Types.Destination) => void;
+}
+
+const CURRENT_STORE_VERSION = 0;
+
+export const useMessageStore = create()(
+ persist(
+ (set, get) => ({
+ messages: {
+ direct: {}, // Record>>
+ broadcast: {},
+ },
+ draft: new Map(),
+ activeChat: 0,
+ chatType: MessageType.Broadcast,
+ nodeNum: 0,
+ setNodeNum: (nodeNum) => {
+ set(produce((state: MessageStore) => {
+ state.nodeNum = nodeNum;
+ }));
+ },
+ getNodeNum: () => get().nodeNum,
+ setActiveChat: (chat) => {
+ set(produce((state: MessageStore) => {
+ state.activeChat = chat;
+ }));
+ },
+ setChatType: (type) => {
+ set(produce((state: MessageStore) => {
+ state.chatType = type;
+ }));
+ },
+ saveMessage: (message) => {
+ set(produce((state: MessageStore) => {
+ if (message.type === MessageType.Direct) {
+ const sender = Number(message.from);
+ const recipient = Number(message.to);
+
+ if (!state.messages.direct[sender]) {
+ state.messages.direct[sender] = {};
+ }
+ if (!state.messages.direct[sender][recipient]) {
+ state.messages.direct[sender][recipient] = {};
+ }
+ state.messages.direct[sender][recipient][message.messageId] = message;
+
+ } else if (message.type === MessageType.Broadcast) {
+ const channel = Number(message.channel);
+ if (!state.messages.broadcast[channel]) {
+ state.messages.broadcast[channel] = {};
+ }
+ state.messages.broadcast[channel][message.messageId] = message;
+ }
+ }));
+ },
+ setMessageState: ({
+ type,
+ key,
+ messageId,
+ newState = MessageState.Ack,
+ }) => {
+ set(
+ produce((state: MessageStore) => {
+ let message: Message | undefined;
+
+ if (type === MessageType.Broadcast) {
+ const channel = key;
+ message = state.messages.broadcast?.[channel]?.[messageId];
+ } else if (type === MessageType.Direct) {
+ const otherNodeNum = key;
+ const myNodeNum = state.nodeNum;
+
+ message = state.messages.direct?.[myNodeNum]?.[otherNodeNum]?.[messageId];
+
+ if (!message) {
+ message = state.messages.direct?.[otherNodeNum]?.[myNodeNum]?.[messageId];
+ }
+ }
+
+ if (message) {
+ message.state = newState;
+ } else {
+ console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`);
+ }
+ }),
+ );
+ },
+ getMessages: (type, options) => {
+ const state = get();
+
+ if (type === MessageType.Broadcast && options.channel !== undefined) {
+ const messageMap = state.messages.broadcast[options.channel] ?? {};
+ return Object.values(messageMap).sort((a, b) => a.date - b.date);
+ }
+
+ if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
+ const myNodeNum = options.myNodeNum;
+ const otherNodeNum = options.otherNodeNum;
+
+ // Messages sent BY ME TO OTHER
+ const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {};
+ const sentByMe = Object.values(sentByMeMap);
+
+ // Messages sent BY OTHER TO ME
+ const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {};
+ const sentByOther = Object.values(sentByOtherMap);
+
+ // Merge and sort chronologically
+ return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date);
+ }
+ return [];
+ },
+ clearMessageByMessageId: ({ type, sender, recipient, channel, messageId }) => {
+ set(produce((state: MessageStore) => {
+ if (type === MessageType.Broadcast && channel !== undefined) {
+ const messageMap = state.messages.broadcast[channel];
+ if (messageMap?.[messageId]) {
+ delete messageMap[messageId];
+ if (Object.keys(messageMap).length === 0) {
+ delete state.messages.broadcast[channel];
+ }
+ }
+ } else if (type === MessageType.Direct && sender !== undefined && recipient !== undefined) {
+ const messageMap = state.messages.direct?.[sender]?.[recipient];
+ if (messageMap?.[messageId]) {
+ delete messageMap[messageId];
+ if (Object.keys(messageMap).length === 0) {
+ delete state.messages.direct[sender][recipient];
+ if (Object.keys(state.messages.direct[sender]).length === 0) {
+ delete state.messages.direct[sender];
+ }
+ }
+ }
+ console.warn("clearMessageByMessageId called without sufficient identifiers for type", type);
+ }
+ }));
+ },
+ getDraft: (key) => {
+ return get().draft.get(key) ?? '';
+ },
+ setDraft: (key, message) => {
+ set(produce((state: MessageStore) => {
+ state.draft.set(key, message);
+ }));
+ },
+ clearDraft: (key) => {
+ set(produce((state: MessageStore) => {
+ state.draft.delete(key);
+ }));
+ },
+ clearAllMessages: () => {
+ set(produce((state: MessageStore) => {
+ state.messages.direct = {};
+ state.messages.broadcast = {};
+ }));
+ }
+ }),
+ {
+ name: 'meshtastic-message-store',
+ storage: createJSONStorage(() => zustandIndexDBStorage),
+ version: CURRENT_STORE_VERSION,
+ partialize: (state) => ({
+ messages: state.messages,
+ nodeNum: state.nodeNum,
+ }),
+ }
+ ));
\ No newline at end of file
diff --git a/src/core/stores/storage/indexDB.ts b/src/core/stores/storage/indexDB.ts
new file mode 100644
index 00000000..b6f08d50
--- /dev/null
+++ b/src/core/stores/storage/indexDB.ts
@@ -0,0 +1,14 @@
+import { StateStorage } from "zustand/middleware";
+import { get, set, del } from "idb-keyval";
+
+export const zustandIndexDBStorage: StateStorage = {
+ getItem: async (name: string): Promise => {
+ return (await get(name)) || null;
+ },
+ setItem: async (name: string, value: string): Promise => {
+ await set(name, value);
+ },
+ removeItem: async (name: string): Promise => {
+ await del(name);
+ },
+};
\ No newline at end of file
diff --git a/src/core/subscriptions.ts b/src/core/subscriptions.ts
index 355d082b..9dd2bd32 100644
--- a/src/core/subscriptions.ts
+++ b/src/core/subscriptions.ts
@@ -1,10 +1,14 @@
import type { Device } from "@core/stores/deviceStore.ts";
import { MeshDevice, Protobuf } from "@meshtastic/core";
+import type { MessageStore, MessageType } from "@core/stores/messageStore.ts";
+import { MessageType } from "@core/stores/messageStore.ts";
+import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
export const subscribeAll = (
device: Device,
connection: MeshDevice,
+ messageStore: MessageStore
) => {
let myNodeNum = 0;
@@ -52,6 +56,7 @@ export const subscribeAll = (
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
device.setHardware(nodeInfo);
+ messageStore.setNodeNum(nodeInfo.myNodeNum);
myNodeNum = nodeInfo.myNodeNum;
});
@@ -82,18 +87,15 @@ export const subscribeAll = (
connection.events.onMessagePacket.subscribe((messagePacket) => {
- device.addMessage({
- ...messagePacket,
- state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
- });
- if (messagePacket.type == "direct")
- {
+ const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
+ const message = dto.toMessage();
+ messsageStore.saveMessage(message);
+
+ message.type == MessageType.Direct
+ ?
device.setUnread(messagePacket.from);
- }
- else
- {
- device.setUnread(messagePacket.channel);
- }
+ :
+ device.setUnread(messagePacket.channel);
});
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
@@ -114,9 +116,6 @@ export const subscribeAll = (
});
});
- connection.events.onQueueStatus.subscribe((queueStatus) => {
- device.setQueueStatus(queueStatus);
- });
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
diff --git a/src/core/utils/ip.ts b/src/core/utils/ip.ts
index 0dbc5ca5..70ac97f6 100644
--- a/src/core/utils/ip.ts
+++ b/src/core/utils/ip.ts
@@ -1,10 +1,12 @@
export function convertIntToIpAddress(int: number): string {
- return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${
- (int >> 24) & 0xff
- }`;
+ return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff
+ }`;
}
-export function convertIpAddressToInt(ip: string): number | null {
+export function convertIpAddressToInt(ip: string): number | undefined {
+ if (!ip) {
+ return undefined;
+ }
return (
ip
.split(".")
diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx
index 45ae5f19..b97e8640 100644
--- a/src/pages/Config/DeviceConfig.tsx
+++ b/src/pages/Config/DeviceConfig.tsx
@@ -2,7 +2,7 @@ import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
import { Device } from "../../components/PageComponents/Config/Device/index.tsx";
import { Display } from "@components/PageComponents/Config/Display.tsx";
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
-import { Network } from "@components/PageComponents/Config/Network.tsx";
+import { Network } from "../../components/PageComponents/Config/Network/index.tsx";
import { Position } from "@components/PageComponents/Config/Position.tsx";
import { Power } from "@components/PageComponents/Config/Power.tsx";
import { Security } from "../../components/PageComponents/Config/Security/Security.tsx";
@@ -31,7 +31,6 @@ export const DeviceConfig = () => {
{
label: "Network",
element: Network,
- // disabled: !metadata.get(0)?.hasWifi,
},
{
label: "Display",
diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx
index b59afe72..ef11ff9d 100644
--- a/src/pages/Messages.tsx
+++ b/src/pages/Messages.tsx
@@ -1,4 +1,3 @@
-import { useAppStore } from "../core/stores/appStore.ts";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
@@ -14,11 +13,15 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useState } from "react";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { cn } from "@core/utils/cn.ts";
+import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
export const MessagesPage = () => {
- const { channels, nodes, hardware, messages, hasNodeError, unreadCounts, setUnread } = useDevice();
- const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
+ const { channels, nodes, hardware, hasNodeError, unreadCounts, setUnread } = useDevice();
+ const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
+ const { toast } = useToast();
+
const [searchTerm, setSearchTerm] = useState("");
+
const filteredNodes = Array.from(nodes.values()).filter((node) => {
if (node.num === hardware.myNodeNum) return false;
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
@@ -34,14 +37,15 @@ export const MessagesPage = () => {
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
const currentChannel = channels.get(activeChat);
- const { toast } = useToast();
- const node = nodes.get(activeChat);
- const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
- const messageDestination = chatType === "direct" ? activeChat : "broadcast";
- const messageChannel = chatType === "direct"
- ? Types.ChannelNumber.Primary
- : activeChat;
+ const otherNode = nodes.get(activeChat);
+
+ const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
+
+ const isDirect = chatType === MessageType.Direct;
+ const isBroadcast = chatType === MessageType.Broadcast;
+
+ const currentChat = { type: chatType, id: activeChat };
return (
<>
@@ -56,9 +60,8 @@ export const MessagesPage = () => {
: channel.index === 0
? "Primary"
: `Ch ${channel.index}`}
- active={activeChat === channel.index && chatType === "broadcast"}
onClick={() => {
- setChatType("broadcast");
+ setChatType(MessageType.Broadcast);
setActiveChat(channel.index);
setUnread(channel.index, 0);
}}
@@ -77,23 +80,22 @@ export const MessagesPage = () => {
/>