Merge branch 'master' into unread-counts
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts";
|
||||
|
||||
class PacketToMessageDTO {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: number; // (timestamp ms)
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
type: MessageType;
|
||||
|
||||
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
|
||||
this.channel = data.channel;
|
||||
this.to = data.to;
|
||||
this.from = data.from;
|
||||
this.messageId = data.id;
|
||||
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
|
||||
this.message = data.data;
|
||||
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast;
|
||||
|
||||
let dateTimestamp = Date.now();
|
||||
if (data.rxTime instanceof Date) {
|
||||
const timeValue = data.rxTime.getTime();
|
||||
|
||||
if (!isNaN(timeValue)) {
|
||||
dateTimestamp = timeValue;
|
||||
}
|
||||
}
|
||||
else if (data.rxTime != null) {
|
||||
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`);
|
||||
}
|
||||
this.date = dateTimestamp;
|
||||
}
|
||||
|
||||
toMessage(): Message {
|
||||
return {
|
||||
channel: this.channel,
|
||||
to: this.to,
|
||||
from: this.from,
|
||||
date: this.date,
|
||||
messageId: this.messageId,
|
||||
state: this.state,
|
||||
message: this.message,
|
||||
type: this.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default PacketToMessageDTO;
|
||||
@@ -1,71 +1,58 @@
|
||||
import { Button } from "../../components/UI/Button.tsx";
|
||||
import type { CookieAttributes } from "js-cookie";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import useCookie from "./useCookie.ts";
|
||||
import { useToast } from "./useToast.ts";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
|
||||
|
||||
interface UseBackupReminderOptions {
|
||||
reminderInDays?: number;
|
||||
message: string;
|
||||
onAccept?: () => void | Promise<void>;
|
||||
enabled: boolean;
|
||||
cookieOptions?: CookieAttributes;
|
||||
}
|
||||
|
||||
interface ReminderState {
|
||||
suppressed: boolean;
|
||||
lastShown: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds;
|
||||
const TOAST_DURATION = 30_000; // 30 seconds;:
|
||||
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds
|
||||
const TOAST_DURATION = 30_000; // 30 seconds
|
||||
const REMINDER_DAYS_ONE_WEEK = 7;
|
||||
const REMINDER_DAYS_ONE_YEAR = 365;
|
||||
const REMINDER_DAYS_FOREVER = 3650;
|
||||
const STORAGE_KEY = "key_backup_reminder";
|
||||
|
||||
// remind user in 1 year to backup keys again, if they accept the reminder;
|
||||
const ON_ACCEPT_REMINDER_DAYS = 365;
|
||||
function isReminderExpired(expires?: string): boolean {
|
||||
if (!expires) return true;
|
||||
const expiryDate = new Date(expires);
|
||||
if (isNaN(expiryDate.getTime())) return true; // Invalid date passed
|
||||
|
||||
function isReminderExpired(lastShown: string): boolean {
|
||||
const lastShownDate = new Date(lastShown);
|
||||
const now = new Date();
|
||||
const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24);
|
||||
return daysSinceLastShown >= 7;
|
||||
return now.getTime() >= expiryDate.getTime();
|
||||
}
|
||||
|
||||
export function useBackupReminder({
|
||||
reminderInDays = 7,
|
||||
enabled,
|
||||
message,
|
||||
onAccept = () => {},
|
||||
cookieOptions,
|
||||
onAccept = () => { },
|
||||
reminderInDays = REMINDER_DAYS_ONE_WEEK,
|
||||
}: UseBackupReminderOptions) {
|
||||
const { toast } = useToast();
|
||||
const toastShownRef = useRef(false);
|
||||
const { value: reminderCookie, setCookie } = useCookie<ReminderState>(
|
||||
"key_backup_reminder",
|
||||
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>(
|
||||
STORAGE_KEY,
|
||||
null
|
||||
);
|
||||
|
||||
const suppressReminder = useCallback(
|
||||
(days: number) => {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setDate(expiryDate.getDate() + days);
|
||||
|
||||
setCookie(
|
||||
{
|
||||
suppressed: true,
|
||||
lastShown: new Date().toISOString(),
|
||||
},
|
||||
{ ...cookieOptions, expires: expiryDate },
|
||||
);
|
||||
},
|
||||
[setCookie, cookieOptions],
|
||||
);
|
||||
const setReminderExpiry = useCallback((days: number) => {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setDate(expiryDate.getDate() + days);
|
||||
setReminderState({ expires: expiryDate.toISOString() });
|
||||
}, [setReminderState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || toastShownRef.current) return;
|
||||
|
||||
const shouldShowReminder = !reminderCookie?.suppressed ||
|
||||
isReminderExpired(reminderCookie.lastShown);
|
||||
if (!shouldShowReminder) return;
|
||||
if (!isReminderExpired(reminderState?.expires)) return;
|
||||
|
||||
toastShownRef.current = true;
|
||||
|
||||
@@ -75,44 +62,52 @@ export function useBackupReminder({
|
||||
delay: TOAST_APPEAR_DELAY,
|
||||
description: message,
|
||||
action: (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="p-1"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
setReminderExpiry(reminderInDays);
|
||||
}}
|
||||
>
|
||||
Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="p-1"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
setReminderExpiry(REMINDER_DAYS_FOREVER);
|
||||
}}
|
||||
>
|
||||
Never remind me
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onAccept();
|
||||
dismiss();
|
||||
suppressReminder(ON_ACCEPT_REMINDER_DAYS);
|
||||
setReminderExpiry(REMINDER_DAYS_ONE_YEAR);
|
||||
}}
|
||||
>
|
||||
Back up now
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
suppressReminder(reminderInDays);
|
||||
}}
|
||||
>
|
||||
Remind me in {reminderInDays} days
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (!toastShownRef.current) {
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
return () => dismiss();
|
||||
}, [
|
||||
enabled,
|
||||
message,
|
||||
onAccept,
|
||||
reminderInDays,
|
||||
suppressReminder,
|
||||
toast,
|
||||
reminderCookie,
|
||||
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string[]>(storageName, []);
|
||||
|
||||
const togglePinnedItem = useCallback((label: string) => {
|
||||
setPinnedItems((prev) =>
|
||||
prev.includes(label)
|
||||
? prev.filter((g) => g !== label)
|
||||
: [...prev, label]
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pinnedItems,
|
||||
togglePinnedItem,
|
||||
};
|
||||
}
|
||||
@@ -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: <Button>Backup Now</Button>
|
||||
})
|
||||
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()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<number, number>;
|
||||
|
||||
@@ -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<AppState>()((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<AppState>()((set, get) => ({
|
||||
set(() => ({
|
||||
nodeNumDetails: nodeNum,
|
||||
})),
|
||||
setActiveChat: (chat) =>
|
||||
set(() => ({
|
||||
activeChat: chat,
|
||||
})),
|
||||
setChatType: (type) =>
|
||||
set(() => ({
|
||||
chatType: type,
|
||||
})),
|
||||
hasErrors: () => {
|
||||
const state = get();
|
||||
return state.errors.length > 0;
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface MessageWithState extends Types.PacketMetadata<string> {
|
||||
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<number, Protobuf.Mesh.NodeInfo>;
|
||||
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
||||
messages: {
|
||||
direct: Map<number, MessageWithState[]>;
|
||||
broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
|
||||
};
|
||||
traceroutes: Map<
|
||||
number,
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||
@@ -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<number, number>;
|
||||
|
||||
@@ -99,26 +93,16 @@ export interface Device {
|
||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||
addConnection: (connection: MeshDevice) => void;
|
||||
addMessage: (message: MessageWithState) => void;
|
||||
addTraceRoute: (
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||
) => 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<DeviceState>((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<DeviceState>((set, get) => ({
|
||||
nodeDetails: false,
|
||||
unsafeRoles: false,
|
||||
refreshKeys: false,
|
||||
rebootOTA: false,
|
||||
},
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
@@ -509,31 +486,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
addMessage: (message) => {
|
||||
set(
|
||||
produce<DeviceState>((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<DeviceState>((draft) => {
|
||||
@@ -574,43 +526,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
setMessageState: (
|
||||
type: "direct" | "broadcast",
|
||||
channelIndex: Types.ChannelNumber,
|
||||
to: number,
|
||||
from: number,
|
||||
messageId: number,
|
||||
state: MessageState,
|
||||
) => {
|
||||
set(
|
||||
produce<DeviceState>((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<DeviceState>((draft) => {
|
||||
@@ -680,17 +595,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
},
|
||||
setQueueStatus: (status: QueueStatus) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
device.queueStatus = status;
|
||||
device.queueStatus.free >= 10 ? true : false
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNodeError: (nodeNum, error) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useMessageStore,
|
||||
MessageType,
|
||||
MessageState,
|
||||
type Message,
|
||||
} from './messageStore.ts';
|
||||
|
||||
let memoryStorage: Record<string, string> = {};
|
||||
|
||||
vi.mock('./storage/indexDB.ts', () => {
|
||||
console.log("Mocking zustandIndexDBStorage...");
|
||||
return {
|
||||
zustandIndexDBStorage: {
|
||||
getItem: vi.fn(async (name: string): Promise<string | null> => {
|
||||
console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null);
|
||||
return memoryStorage[name] ?? null;
|
||||
}),
|
||||
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
|
||||
console.log(`Mock setItem: ${name}`, value);
|
||||
memoryStorage[name] = value;
|
||||
}),
|
||||
removeItem: vi.fn(async (name: string): Promise<void> => {
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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<T extends MessageType> extends MessageBase {
|
||||
type: T;
|
||||
}
|
||||
|
||||
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
|
||||
|
||||
export interface MessageStore {
|
||||
messages: {
|
||||
direct: Record<number, Record<number, Record<number, Message>>>;
|
||||
broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
|
||||
};
|
||||
draft: Map<Types.Destination, string>;
|
||||
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<MessageStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messages: {
|
||||
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
|
||||
broadcast: {},
|
||||
},
|
||||
draft: new Map<number, string>(),
|
||||
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,
|
||||
}),
|
||||
}
|
||||
));
|
||||
@@ -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<string | null> => {
|
||||
return (await get(name)) || null;
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await set(name, value);
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await del(name);
|
||||
},
|
||||
};
|
||||
+13
-14
@@ -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") {
|
||||
|
||||
@@ -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(".")
|
||||
|
||||
Reference in New Issue
Block a user