chore: lint/format all files (#604)

* chore: lint/format all files

* Fix config sidebar button state (#602)

* chore: Update deno.lock version and add Radix UI slider component (#601)

* fix: improve how table addresses even/odd rows

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com>
This commit is contained in:
Dan Ditomaso
2025-05-04 15:55:04 -04:00
committed by GitHub
co-authored by philon- Kamil Dzieniszewski
parent b7bdb1a502
commit 480ca46a95
104 changed files with 3104 additions and 2527 deletions
+9 -4
View File
@@ -3,7 +3,7 @@ import { Protobuf } from "@meshtastic/core";
class NodeInfoFactory {
private static createDefaultUser(num: number): Protobuf.Mesh.User {
const userIdHex = num.toString(16).toUpperCase().padStart(2, '0');
const userIdHex = num.toString(16).toUpperCase().padStart(2, "0");
const userId = `!${userIdHex}`;
const last4 = userIdHex.slice(-4);
const longName = `Meshtastic ${last4}`;
@@ -19,14 +19,19 @@ class NodeInfoFactory {
});
}
public static ensureDefaultUser(node: Protobuf.Mesh.NodeInfo): Protobuf.Mesh.NodeInfo {
public static ensureDefaultUser(
node: Protobuf.Mesh.NodeInfo,
): Protobuf.Mesh.NodeInfo {
if (!node) {
return node;
}
if (!node.user) {
if (node.num === undefined || node.num === null) {
console.error(`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`, node);
console.error(
`NodeInfoFactory.ensureDefaultUser: Cannot create default user for node because 'num' is missing.`,
node,
);
return node;
}
@@ -37,4 +42,4 @@ class NodeInfoFactory {
}
}
export default NodeInfoFactory;
export default NodeInfoFactory;
+17 -7
View File
@@ -1,5 +1,9 @@
import type { Types } from "@meshtastic/js";
import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts";
import {
Message,
MessageState,
MessageType,
} from "../stores/messageStore/index.ts";
class PacketToMessageDTO {
channel: Types.ChannelNumber;
@@ -16,9 +20,13 @@ class PacketToMessageDTO {
this.to = data.to;
this.from = data.from;
this.messageId = data.id;
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
this.state = data.from !== nodeNum
? MessageState.Ack
: MessageState.Waiting;
this.message = data.data;
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast;
this.type = (data.type === "direct")
? MessageType.Direct
: MessageType.Broadcast;
let dateTimestamp = Date.now();
if (data.rxTime instanceof Date) {
@@ -27,9 +35,11 @@ class PacketToMessageDTO {
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.`);
} 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;
}
@@ -48,4 +58,4 @@ class PacketToMessageDTO {
}
}
export default PacketToMessageDTO;
export default PacketToMessageDTO;
+7 -5
View File
@@ -4,7 +4,9 @@ interface UseCopyToClipboardProps {
timeout?: number;
}
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) {
export function useCopyToClipboard(
{ timeout = 2000 }: UseCopyToClipboardProps = {},
) {
const [isCopied, setIsCopied] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
@@ -19,7 +21,7 @@ export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps =
const copy = useCallback(
async (text: string) => {
if (!navigator?.clipboard) {
console.warn('Clipboard API not available');
console.warn("Clipboard API not available");
setIsCopied(false);
return false;
}
@@ -39,13 +41,13 @@ export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps =
return true;
} catch (error) {
console.error('Failed to copy text to clipboard:', error);
console.error("Failed to copy text to clipboard:", error);
setIsCopied(false);
return false;
}
},
[timeout]
[timeout],
);
return { isCopied, copy };
}
}
+7 -6
View File
@@ -33,14 +33,16 @@ function isReminderExpired(expires?: string): boolean {
export function useBackupReminder({
enabled,
message,
onAccept = () => { },
onAccept = () => {},
reminderInDays = REMINDER_DAYS_ONE_WEEK,
}: UseBackupReminderOptions) {
const { toast } = useToast();
const toastShownRef = useRef(false);
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>(
const [reminderState, setReminderState] = useLocalStorage<
ReminderState | null
>(
STORAGE_KEY,
null
null,
);
const setReminderExpiry = useCallback((days: number) => {
@@ -73,7 +75,7 @@ export function useBackupReminder({
setReminderExpiry(reminderInDays);
}}
>
Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''}
Remind me in {reminderInDays} day{reminderInDays > 1 ? "s" : ""}
</Button>
<Button
type="button"
@@ -108,6 +110,5 @@ export function useBackupReminder({
enabled,
message,
onAccept,
]);
};
}
+36 -36
View File
@@ -1,52 +1,52 @@
import { renderHook, act } from '@testing-library/react'
import useLocalStorage from './useLocalStorage'
import { act, renderHook } from "@testing-library/react";
import useLocalStorage from "./useLocalStorage.ts";
import { beforeEach, describe, expect, it } from "vitest";
describe('useLocalStorage', () => {
const key = 'test-key'
describe("useLocalStorage", () => {
const key = "test-key";
beforeEach(() => {
localStorage.clear()
})
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 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 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
it("should update localStorage when setValue is called", () => {
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [, setValue] = result.current;
act(() => {
setValue('updated')
})
setValue("updated");
});
expect(localStorage.getItem(key)).toBe(JSON.stringify('updated'))
expect(result.current[0]).toBe('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
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')
})
setValue("to-be-removed");
});
act(() => {
removeValue()
})
removeValue();
});
expect(localStorage.getItem(key)).toBeNull()
expect(result.current[0]).toBe('initial')
})
})
expect(localStorage.getItem(key)).toBeNull();
expect(result.current[0]).toBe("initial");
});
});
+15 -12
View File
@@ -55,8 +55,9 @@ export default function useLocalStorage<T>(
return undefined as unknown as T;
}
const defaultValue =
initialValue instanceof Function ? initialValue() : initialValue;
const defaultValue = initialValue instanceof Function
? initialValue()
: initialValue;
let parsed: unknown;
try {
@@ -74,8 +75,9 @@ export default function useLocalStorage<T>(
// Get from local storage then
// parse stored json or return initialValue
const readValue = useCallback((): T => {
const initialValueToUse =
initialValue instanceof Function ? initialValue() : initialValue;
const initialValueToUse = initialValue instanceof Function
? initialValue()
: initialValue;
// Prevent build error "window is undefined" but keep working
if (IS_SERVER) {
@@ -83,7 +85,7 @@ export default function useLocalStorage<T>(
}
try {
const raw = window.localStorage.getItem(key);
const raw = globalThis.localStorage.getItem(key);
return raw ? deserializer(raw) : initialValueToUse;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
@@ -115,13 +117,13 @@ export default function useLocalStorage<T>(
const newValue = value instanceof Function ? value(readValue()) : value;
// Save to local storage
window.localStorage.setItem(key, serializer(newValue));
globalThis.localStorage.setItem(key, serializer(newValue));
// Save state
setStoredValue(newValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
window.dispatchEvent(new StorageEvent("local-storage", { key }));
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error);
}
@@ -137,17 +139,18 @@ export default function useLocalStorage<T>(
);
}
const defaultValue =
initialValue instanceof Function ? initialValue() : initialValue;
const defaultValue = initialValue instanceof Function
? initialValue()
: initialValue;
// Remove the key from local storage
window.localStorage.removeItem(key);
globalThis.localStorage.removeItem(key);
// Save state with default value
setStoredValue(defaultValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
window.dispatchEvent(new StorageEvent("local-storage", { key }));
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
}, [key]);
useEffect(() => {
@@ -176,4 +179,4 @@ export default function useLocalStorage<T>(
}, []);
return [storedValue, setValue, removeValue];
}
}
@@ -1,22 +1,22 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { usePasswordVisibilityToggle } from './usePasswordVisibilityToggle.ts';
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { usePasswordVisibilityToggle } from "./usePasswordVisibilityToggle.ts";
describe('usePasswordVisibilityToggle Hook', () => {
it('should initialize with visibility set to false by default', () => {
describe("usePasswordVisibilityToggle Hook", () => {
it("should initialize with visibility set to false by default", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
expect(typeof result.current.toggleVisibility).toBe('function');
expect(typeof result.current.toggleVisibility).toBe("function");
});
it('should initialize with visibility set to true if initialVisible is true', () => {
it("should initialize with visibility set to true if initialVisible is true", () => {
const { result } = renderHook(() =>
usePasswordVisibilityToggle({ initialVisible: true })
);
expect(result.current.isVisible).toBe(true);
});
it('should toggle visibility from false to true when toggleVisibility is called', () => {
it("should toggle visibility from false to true when toggleVisibility is called", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
act(() => {
@@ -25,7 +25,7 @@ describe('usePasswordVisibilityToggle Hook', () => {
expect(result.current.isVisible).toBe(true);
});
it('should toggle visibility from true to false when toggleVisibility is called', () => {
it("should toggle visibility from true to false when toggleVisibility is called", () => {
const { result } = renderHook(() =>
usePasswordVisibilityToggle({ initialVisible: true })
);
@@ -36,7 +36,7 @@ describe('usePasswordVisibilityToggle Hook', () => {
expect(result.current.isVisible).toBe(false);
});
it('should toggle visibility correctly multiple times', () => {
it("should toggle visibility correctly multiple times", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
act(() => {
@@ -53,8 +53,10 @@ describe('usePasswordVisibilityToggle Hook', () => {
expect(result.current.isVisible).toBe(true);
});
it('should return a stable toggleVisibility function reference (due to useCallback)', () => {
const { result, rerender } = renderHook(() => usePasswordVisibilityToggle());
it("should return a stable toggleVisibility function reference (due to useCallback)", () => {
const { result, rerender } = renderHook(() =>
usePasswordVisibilityToggle()
);
const initialToggleFunc = result.current.toggleVisibility;
rerender();
expect(result.current.toggleVisibility).toBe(initialToggleFunc);
@@ -63,4 +65,4 @@ describe('usePasswordVisibilityToggle Hook', () => {
});
expect(result.current.isVisible).toBe(true);
});
});
});
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useCallback, useState } from "react";
interface UsePasswordVisibilityToggleProps {
initialVisible?: boolean;
@@ -6,15 +6,17 @@ interface UsePasswordVisibilityToggleProps {
/**
* Manages the state for toggling password visibility.
*
* @param {boolean} [options.initialVisible=false]
* @returns {{isVisible: boolean, toggleVisibility: () => void}}
* @param {boolean} [options.initialVisible=false]
* @returns {{isVisible: boolean, toggleVisibility: () => void}}
*/
export function usePasswordVisibilityToggle({ initialVisible = false }: UsePasswordVisibilityToggleProps = {}) {
export function usePasswordVisibilityToggle(
{ initialVisible = false }: UsePasswordVisibilityToggleProps = {},
) {
const [isVisible, setIsVisible] = useState<boolean>(initialVisible);
const toggleVisibility = useCallback(() => {
setIsVisible(prev => !prev);
setIsVisible((prev) => !prev);
}, []);
return { isVisible, toggleVisibility };
}
}
+7 -4
View File
@@ -1,12 +1,12 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } 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),
default: (...args) => mockUseLocalStorage(...args),
}));
describe("usePinnedItems", () => {
@@ -45,7 +45,10 @@ describe("usePinnedItems", () => {
});
it("removes an item if it's already pinned", () => {
mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]);
mockUseLocalStorage.mockReturnValue([
["item1", "item2"],
mockSetPinnedItems,
]);
const { result } = renderHook(() =>
usePinnedItems({ storageName: "test-storage" })
+5 -4
View File
@@ -2,13 +2,14 @@ import useLocalStorage from "@core/hooks/useLocalStorage.ts";
import { useCallback } from "react";
export function usePinnedItems({ storageName }: { storageName: string }) {
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []);
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(
storageName,
[],
);
const togglePinnedItem = useCallback((label: string) => {
setPinnedItems((prev) =>
prev.includes(label)
? prev.filter((g) => g !== label)
: [...prev, label]
prev.includes(label) ? prev.filter((g) => g !== label) : [...prev, label]
);
}, []);
+51 -53
View File
@@ -1,81 +1,79 @@
import { renderHook, act } from '@testing-library/react'
import { useToast } from "@core/hooks/useToast.ts"
import { Button } from '@components/UI/Button.tsx'
import { act, renderHook } 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', () => {
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()
})
globalThis.memoryState = { toasts: [] };
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
it('should create a toast with title, description, and action', () => {
const { result } = renderHook(() => useToast())
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()
})
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()
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 }
let toastRef: { id: string; dismiss: () => void };
act(() => {
toastRef = result.current.toast({ title: 'Dismiss Me' })
vi.runAllTimers() // Flush ADD_TOAST
})
toastRef = result.current.toast({ title: "Dismiss Me" });
vi.runAllTimers(); // Flush ADD_TOAST
});
act(() => {
toastRef.dismiss()
})
toastRef.dismiss();
});
const toast = result.current.toasts.find(t => t.id === toastRef.id)
expect(toast?.open).toBe(false)
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
expect(toast?.open).toBe(false);
vi.useRealTimers()
})
vi.useRealTimers();
});
it("should allow dismiss via hook dismiss function", () => {
const { result } = renderHook(() => useToast());
vi.useFakeTimers();
it('should allow dismiss via hook dismiss function', () => {
const { result } = renderHook(() => useToast())
vi.useFakeTimers()
let toastRef: { id: string }
let toastRef: { id: string };
act(() => {
toastRef = result.current.toast({ title: 'Manual Dismiss' })
vi.runAllTimers()
})
toastRef = result.current.toast({ title: "Manual Dismiss" });
vi.runAllTimers();
});
act(() => {
result.current.dismiss(toastRef.id)
})
result.current.dismiss(toastRef.id);
});
const toast = result.current.toasts.find(t => t.id === toastRef.id)
expect(toast?.open).toBe(false)
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
expect(toast?.open).toBe(false);
vi.useRealTimers()
})
})
vi.useRealTimers();
});
});
+115 -39
View File
@@ -17,7 +17,7 @@ export type DialogVariant = keyof Device["dialog"];
type NodeError = {
node: number;
error: string;
}
};
export interface Device {
id: number;
@@ -92,7 +92,6 @@ export interface Device {
) => Protobuf.Mesh.NodeInfo[];
getNodesLength: () => number;
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
}
export interface DeviceState {
@@ -110,7 +109,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
remoteDevices: new Map(),
addDevice: (id: number) => {
set(
produce<DeviceState>((draft) => {
draft.devices.set(id, {
@@ -164,14 +162,37 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
const device = draft.devices.get(id);
if (device) {
switch (config.payloadVariant.case) {
case "device": { device.config.device = config.payloadVariant.value; break; }
case "position": { device.config.position = config.payloadVariant.value; break; }
case "power": { device.config.power = config.payloadVariant.value; break; }
case "network": { device.config.network = config.payloadVariant.value; break; }
case "display": { device.config.display = config.payloadVariant.value; break; }
case "lora": { device.config.lora = config.payloadVariant.value; break; }
case "bluetooth": { device.config.bluetooth = config.payloadVariant.value; break; }
case "security": { device.config.security = config.payloadVariant.value; }
case "device": {
device.config.device = config.payloadVariant.value;
break;
}
case "position": {
device.config.position = config.payloadVariant.value;
break;
}
case "power": {
device.config.power = config.payloadVariant.value;
break;
}
case "network": {
device.config.network = config.payloadVariant.value;
break;
}
case "display": {
device.config.display = config.payloadVariant.value;
break;
}
case "lora": {
device.config.lora = config.payloadVariant.value;
break;
}
case "bluetooth": {
device.config.bluetooth = config.payloadVariant.value;
break;
}
case "security": {
device.config.security = config.payloadVariant.value;
}
}
}
}),
@@ -183,18 +204,63 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
const device = draft.devices.get(id);
if (device) {
switch (config.payloadVariant.case) {
case "mqtt": { device.moduleConfig.mqtt = config.payloadVariant.value; break; }
case "serial": { device.moduleConfig.serial = config.payloadVariant.value; break; }
case "externalNotification": { device.moduleConfig.externalNotification = config.payloadVariant.value; break; }
case "storeForward": { device.moduleConfig.storeForward = config.payloadVariant.value; break; }
case "rangeTest": { device.moduleConfig.rangeTest = config.payloadVariant.value; break; }
case "telemetry": { device.moduleConfig.telemetry = config.payloadVariant.value; break; }
case "cannedMessage": { device.moduleConfig.cannedMessage = config.payloadVariant.value; break; }
case "audio": { device.moduleConfig.audio = config.payloadVariant.value; break; }
case "neighborInfo": { device.moduleConfig.neighborInfo = config.payloadVariant.value; break; }
case "ambientLighting": { device.moduleConfig.ambientLighting = config.payloadVariant.value; break; }
case "detectionSensor": { device.moduleConfig.detectionSensor = config.payloadVariant.value; break; }
case "paxcounter": { device.moduleConfig.paxcounter = config.payloadVariant.value; break; }
case "mqtt": {
device.moduleConfig.mqtt = config.payloadVariant.value;
break;
}
case "serial": {
device.moduleConfig.serial = config.payloadVariant.value;
break;
}
case "externalNotification": {
device.moduleConfig.externalNotification =
config.payloadVariant.value;
break;
}
case "storeForward": {
device.moduleConfig.storeForward =
config.payloadVariant.value;
break;
}
case "rangeTest": {
device.moduleConfig.rangeTest =
config.payloadVariant.value;
break;
}
case "telemetry": {
device.moduleConfig.telemetry =
config.payloadVariant.value;
break;
}
case "cannedMessage": {
device.moduleConfig.cannedMessage =
config.payloadVariant.value;
break;
}
case "audio": {
device.moduleConfig.audio = config.payloadVariant.value;
break;
}
case "neighborInfo": {
device.moduleConfig.neighborInfo =
config.payloadVariant.value;
break;
}
case "ambientLighting": {
device.moduleConfig.ambientLighting =
config.payloadVariant.value;
break;
}
case "detectionSensor": {
device.moduleConfig.detectionSensor =
config.payloadVariant.value;
break;
}
case "paxcounter": {
device.moduleConfig.paxcounter =
config.payloadVariant.value;
break;
}
}
}
}),
@@ -216,13 +282,17 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
}),
);
},
setWorkingModuleConfig: (moduleConfig: Protobuf.ModuleConfig.ModuleConfig) => {
setWorkingModuleConfig: (
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
const index = device.workingModuleConfig.findIndex(
(wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
(wmc) =>
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
);
if (index !== -1) {
device.workingModuleConfig[index] = moduleConfig;
@@ -277,7 +347,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id);
const index = device.waypoints.findIndex((wp) =>
wp.id === waypoint.id
);
if (index !== -1) {
device.waypoints[index] = waypoint;
} else {
@@ -288,8 +360,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
);
},
addNodeInfo: (nodeInfo) => {
console.log("Node Info", nodeInfo);
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
@@ -316,11 +386,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) {
return;
}
const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
const currentNode = device.nodesMap.get(user.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.user = user.data;
currentNode.num = user.from;
device.nodesMap.set(user.from, currentNode);
})
}),
);
},
addPosition: (position) => {
@@ -330,11 +401,12 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) {
return;
}
const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
const currentNode = device.nodesMap.get(position.from) ??
create(Protobuf.Mesh.NodeInfoSchema);
currentNode.position = position.data;
currentNode.num = position.from;
device.nodesMap.set(position.from, currentNode);
})
}),
);
},
addConnection: (connection) => {
@@ -373,10 +445,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return
return;
}
device.nodesMap.delete(nodeNum);
}))
}),
);
},
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
set(
@@ -463,7 +536,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) return;
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
device.unreadCounts.set(nodeNum, currentCount + 1);
})
}),
);
},
resetUnread: (nodeNum: number) => {
@@ -475,16 +548,19 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (device.unreadCounts.get(nodeNum) === 0) {
device.unreadCounts.delete(nodeNum);
}
})
}),
);
},
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean): Protobuf.Mesh.NodeInfo[] => {
getNodes: (
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
): Protobuf.Mesh.NodeInfo[] => {
const device = get().devices.get(id);
if (!device) {
return [];
}
const allNodes = Array.from(device.nodesMap.values()).filter(
(node) => node.num !== get().devices.get(id)?.hardware.myNodeNum);
(node) => node.num !== get().devices.get(id)?.hardware.myNodeNum,
);
if (filter) {
return allNodes.filter(filter);
}
@@ -505,7 +581,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
if (!device) {
return 0;
}
return device.nodesMap.size
return device.nodesMap.size;
},
});
}),
+64 -27
View File
@@ -1,9 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { produce } from 'immer';
import { Types } from '@meshtastic/core';
import { storageWithMapSupport } from "../storage/indexDB.ts";
import { ChannelId, ClearMessageParams, ConversationId, GetMessagesParams, Message, MessageId, MessageLogMap, NodeNum, SetMessageStateParams } from "@core/stores/messageStore/types.ts";
import { create } from "zustand";
// import { persist } from "zustand/middleware";
import { produce } from "immer";
import { Types } from "@meshtastic/core";
// import { storageWithMapSupport } from "../storage/indexDB.ts";
import {
ChannelId,
ClearMessageParams,
ConversationId,
GetMessagesParams,
Message,
MessageId,
MessageLogMap,
NodeNum,
SetMessageStateParams,
} from "@core/stores/messageStore/types.ts";
export enum MessageState {
Ack = "ack",
@@ -16,8 +26,11 @@ export enum MessageType {
Broadcast = "broadcast",
}
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
return [node1, node2].sort((a, b) => a - b).join(':');
export function getConversationId(
node1: NodeNum,
node2: NodeNum,
): ConversationId {
return [node1, node2].sort((a, b) => a - b).join(":");
}
export interface MessageStore {
@@ -25,9 +38,9 @@ export interface MessageStore {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
};
}
export interface MessageStore {
messages: MessageStore['messages'];
messages: MessageStore["messages"];
draft: Map<Types.Destination, string>;
nodeNum: number; // This device's node number
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
@@ -47,7 +60,7 @@ export interface MessageStore {
clearDraft: (key: Types.Destination) => void;
}
const CURRENT_STORE_VERSION = 0;
// const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()(
// persist(
@@ -82,17 +95,29 @@ export const useMessageStore = create<MessageStore>()(
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>());
state.messages.direct.set(
conversationId,
new Map<MessageId, Message>(),
);
}
state.messages.direct.get(conversationId)!.set(message.messageId, message);
state.messages.direct.get(conversationId)!.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>());
state.messages.broadcast.set(
channelId,
new Map<MessageId, Message>(),
);
}
state.messages.broadcast.get(channelId)!.set(message.messageId, message);
state.messages.broadcast.get(channelId)!.set(
message.messageId,
message,
);
}
})
}),
);
},
@@ -103,7 +128,10 @@ export const useMessageStore = create<MessageStore>()(
let targetMessage: Message | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
const conversationId = getConversationId(
params.nodeA,
params.nodeB,
);
messageLog = state.messages.direct.get(conversationId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
@@ -118,9 +146,13 @@ export const useMessageStore = create<MessageStore>()(
if (targetMessage) {
targetMessage.state = params.newState ?? MessageState.Ack;
} else {
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`);
console.warn(
`Message or conversation/channel not found for state update. Params: ${
JSON.stringify(params)
}`,
);
}
})
}),
);
},
getMessages: (params: GetMessagesParams): Message[] => {
@@ -130,7 +162,6 @@ export const useMessageStore = create<MessageStore>()(
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);
}
@@ -165,23 +196,29 @@ export const useMessageStore = create<MessageStore>()(
const deleted = messageLog.delete(params.messageId);
if (deleted) {
console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`);
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.`);
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.`);
console.warn(
`Message entry ${parentKey} not found for message deletion.`,
);
}
})
}),
);
},
getDraft: (key) => {
return get().draft.get(key) ?? '';
return get().draft.get(key) ?? "";
},
setDraft: (key, message) => {
set(produce((state: MessageStore) => {
@@ -198,7 +235,7 @@ export const useMessageStore = create<MessageStore>()(
state.messages.direct = new Map<ConversationId, MessageLogMap>();
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
}));
}
},
}),
// {
// name: 'meshtastic-message-store',
@@ -209,4 +246,4 @@ export const useMessageStore = create<MessageStore>()(
// nodeNum: state.nodeNum,
// }),
// })
)
);
+198 -106
View File
@@ -1,25 +1,30 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
useMessageStore,
MessageType,
MessageState,
getConversationId,
} from './index.ts';
import type { ConversationId, ChannelId, MessageLogMap, Message } from './types.ts';
import { Types } from '@meshtastic/core';
MessageState,
MessageType,
useMessageStore,
} from "./index.ts";
import type {
ChannelId,
ConversationId,
Message,
MessageLogMap,
} from "./types.ts";
import { Types } from "@meshtastic/core";
vi.mock('../storage/indexDB.ts', () => {
let memoryStorage: Record<string, string> = {};
vi.mock("../storage/indexDB.ts", () => {
const memoryStorage: Record<string, string> = {};
return {
storageWithMapSupport: {
getItem: vi.fn(async (name: string): Promise<string | null> => {
return memoryStorage[name] ?? null;
return await memoryStorage[name] ?? null;
}),
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
memoryStorage[name] = value;
memoryStorage[name] = await value;
}),
removeItem: vi.fn(async (name: string): Promise<void> => {
delete memoryStorage[name];
await delete memoryStorage[name];
}),
},
};
@@ -38,7 +43,7 @@ const directMessageToOther1: Message = {
date: Date.now(),
messageId: 101,
state: MessageState.Waiting,
message: 'Hello other 1 from me',
message: "Hello other 1 from me",
};
const directMessageFromOther1: Message = {
@@ -49,7 +54,7 @@ const directMessageFromOther1: Message = {
date: Date.now() + 1000,
messageId: 102,
state: MessageState.Waiting,
message: 'Hello me from other 1',
message: "Hello me from other 1",
};
const directMessageToOther2: Message = {
@@ -60,7 +65,7 @@ const directMessageToOther2: Message = {
date: Date.now() + 2000,
messageId: 103,
state: MessageState.Waiting,
message: 'Hello other 2 from me',
message: "Hello other 2 from me",
};
const broadcastMessage1: Message = {
@@ -71,7 +76,7 @@ const broadcastMessage1: Message = {
date: Date.now() + 3000,
messageId: 201,
state: MessageState.Waiting,
message: 'Broadcast message 1',
message: "Broadcast message 1",
};
const broadcastMessage2: Message = {
@@ -82,10 +87,10 @@ const broadcastMessage2: Message = {
date: Date.now() + 4000,
messageId: 202,
state: MessageState.Waiting,
message: 'Broadcast message 2',
message: "Broadcast message 2",
};
describe('useMessageStore', () => {
describe("useMessageStore", () => {
const initialState = useMessageStore.getState();
beforeEach(() => {
@@ -97,10 +102,9 @@ describe('useMessageStore', () => {
},
draft: new Map<Types.Destination, string>(),
}, true);
});
it('should have correct initial state', () => {
it("should have correct initial state", () => {
const state = useMessageStore.getState();
expect(state.messages.direct).toBeInstanceOf(Map);
expect(state.messages.direct.size).toBe(0);
@@ -113,23 +117,26 @@ describe('useMessageStore', () => {
expect(state.chatType).toBe(MessageType.Broadcast);
});
it('should set nodeNum', () => {
it("should set nodeNum", () => {
useMessageStore.getState().setNodeNum(myNodeNum);
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
});
it('should set activeChat and chatType', () => {
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 Map structure', () => {
describe("saveMessage", () => {
it("should save a direct message with correct Map structure", () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
const state = useMessageStore.getState();
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
const conversationId = getConversationId(
directMessageToOther1.from,
directMessageToOther1.to,
);
// Check if the conversation Map exists
expect(state.messages.direct.has(conversationId)).toBe(true);
@@ -139,10 +146,12 @@ describe('useMessageStore', () => {
// 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);
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(
directMessageToOther1,
);
});
it('should save a broadcast message with correct Map structure', () => {
it("should save a broadcast message with correct Map structure", () => {
useMessageStore.getState().saveMessage(broadcastMessage1);
const state = useMessageStore.getState();
const channelId = broadcastMessage1.channel;
@@ -151,10 +160,12 @@ describe('useMessageStore', () => {
const channelLog = state.messages.broadcast.get(channelId);
expect(channelLog).toBeInstanceOf(Map);
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(
broadcastMessage1,
);
});
it('should save multiple messages correctly', () => {
it("should save multiple messages correctly", () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
@@ -162,16 +173,28 @@ describe('useMessageStore', () => {
const state = useMessageStore.getState();
const convId1 = getConversationId(myNodeNum, otherNodeNum1);
expect(state.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
expect(
state.messages.direct.get(convId1)?.get(
directMessageToOther1.messageId,
),
).toEqual(directMessageToOther1);
expect(state.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(directMessageFromOther1);
expect(
state.messages.direct.get(convId1)?.get(
directMessageFromOther1.messageId,
),
).toEqual(directMessageFromOther1);
const channelId = broadcastMessage1.channel;
expect(state.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
expect(
state.messages.broadcast.get(channelId)?.get(
broadcastMessage1.messageId,
),
).toEqual(broadcastMessage1);
});
});
describe('getMessages', () => {
describe("getMessages", () => {
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
@@ -181,56 +204,56 @@ describe('useMessageStore', () => {
useMessageStore.getState().saveMessage(broadcastMessage2);
});
it('should return broadcast messages for a channel, sorted by date', () => {
it("should return broadcast messages for a channel, sorted by date", () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Broadcast,
channelId: broadcastChannel
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', () => {
it("should return empty array for broadcast if channel has no messages", () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Broadcast,
channelId: Types.ChannelNumber.Channel1
channelId: Types.ChannelNumber.Channel1,
});
expect(messages).toEqual([]);
});
it('should return combined direct messages for a specific chat pair, sorted by date', () => {
it("should return combined direct messages for a specific chat pair, sorted by date", () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1
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', () => {
it("should return only relevant direct messages for a different chat pair", () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum2
nodeB: otherNodeNum2,
});
expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(directMessageToOther2);
});
it('should return empty array for direct chat if no messages exist', () => {
it("should return empty array for direct chat if no messages exist", () => {
const messages = useMessageStore.getState().getMessages({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 999
nodeB: 999,
});
expect(messages).toEqual([]);
});
});
describe('setMessageState', () => {
describe("setMessageState", () => {
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
@@ -238,7 +261,7 @@ describe('useMessageStore', () => {
useMessageStore.getState().saveMessage(broadcastMessage1);
});
it('should update state for a direct message', () => {
it("should update state for a direct message", () => {
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
@@ -246,12 +269,17 @@ describe('useMessageStore', () => {
messageId: directMessageToOther1.messageId,
newState: MessageState.Ack,
});
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageToOther1.messageId);
const conversationId = getConversationId(
directMessageToOther1.from,
directMessageToOther1.to,
);
const message = useMessageStore.getState().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', () => {
it("should update state for another direct message in the same conversation", () => {
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: directMessageFromOther1.from,
@@ -259,24 +287,31 @@ describe('useMessageStore', () => {
messageId: directMessageFromOther1.messageId,
newState: MessageState.Failed,
});
const conversationId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageFromOther1.messageId);
const conversationId = getConversationId(
directMessageFromOther1.from,
directMessageFromOther1.to,
);
const message = useMessageStore.getState().messages.direct.get(
conversationId,
)?.get(directMessageFromOther1.messageId);
expect(message?.state).toBe(MessageState.Failed);
});
it('should update state for a broadcast message', () => {
it("should update state for a broadcast message", () => {
useMessageStore.getState().setMessageState({
type: MessageType.Broadcast,
channelId: broadcastChannel,
messageId: broadcastMessage1.messageId,
newState: MessageState.Ack,
});
const message = useMessageStore.getState().messages.broadcast.get(broadcastChannel)?.get(broadcastMessage1.messageId);
const message = useMessageStore.getState().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(() => { });
it("should warn if message is not found (direct)", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: myNodeNum,
@@ -284,24 +319,32 @@ describe('useMessageStore', () => {
messageId: 999,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
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(() => { });
it("should warn if message is not found (broadcast)", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
useMessageStore.getState().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'));
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(() => { });
it("should warn if conversation/channel is not found", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: myNodeNum,
@@ -309,22 +352,30 @@ describe('useMessageStore', () => {
messageId: 101,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Message or conversation/channel not found for state update",
),
);
warnSpy.mockRestore();
});
});
describe('clearMessageByMessageId', () => {
describe("clearMessageByMessageId", () => {
const extraDirectMessageId = 1011;
beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: extraDirectMessageId, date: Date.now() + 50 });
useMessageStore.getState().saveMessage({
...directMessageToOther1,
messageId: extraDirectMessageId,
date: Date.now() + 50,
});
});
it('should delete a specific direct message', () => {
it("should delete a specific direct message", () => {
const messageIdToDelete = directMessageToOther1.messageId;
const nodeA = directMessageToOther1.from;
const nodeB = directMessageToOther1.to;
@@ -334,19 +385,20 @@ describe('useMessageStore', () => {
type: MessageType.Direct,
nodeA: nodeA,
nodeB: nodeB,
messageId: messageIdToDelete
messageId: messageIdToDelete,
});
const state = useMessageStore.getState();
const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true);
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(
true,
);
expect(state.messages.direct.has(conversationId)).toBe(true);
});
it('should delete another specific direct message', () => {
it("should delete another specific direct message", () => {
const messageIdToDelete = directMessageFromOther1.messageId;
const nodeA = directMessageFromOther1.from;
const nodeB = directMessageFromOther1.to;
@@ -356,7 +408,7 @@ describe('useMessageStore', () => {
type: MessageType.Direct,
nodeA: nodeA,
nodeB: nodeB,
messageId: messageIdToDelete
messageId: messageIdToDelete,
});
const state = useMessageStore.getState();
@@ -366,38 +418,63 @@ describe('useMessageStore', () => {
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
});
it('should delete a specific broadcast message', () => {
it("should delete a specific broadcast message", () => {
const messageIdToDelete = broadcastMessage1.messageId;
const channelId = broadcastMessage1.channel;
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Broadcast,
channelId: channelId,
messageId: messageIdToDelete
messageId: messageIdToDelete,
});
const state = useMessageStore.getState();
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined();
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete))
.toBeUndefined();
});
it('should clean up empty conversation/channel Maps', () => {
const directConvId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
it("should clean up empty conversation/channel Maps", () => {
const directConvId = getConversationId(
directMessageFromOther1.from,
directMessageFromOther1.to,
);
const broadcastChanId = broadcastMessage1.channel;
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: directMessageToOther1.messageId });
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, messageId: directMessageFromOther1.messageId });
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: extraDirectMessageId });
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: directMessageToOther1.messageId,
});
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageFromOther1.from,
nodeB: directMessageFromOther1.to,
messageId: directMessageFromOther1.messageId,
});
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: extraDirectMessageId,
});
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(false);
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(
false,
);
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId });
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Broadcast,
channelId: broadcastChanId,
messageId: broadcastMessage1.messageId,
});
expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId)).toBe(false);
expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId))
.toBe(false);
});
it('should not error when trying to delete non-existent message', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
it("should not error when trying to delete non-existent message", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
expect(() => {
@@ -405,30 +482,34 @@ describe('useMessageStore', () => {
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
messageId: 9999
messageId: 9999,
});
}).not.toThrow();
const state = useMessageStore.getState();
const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not found in direct chat'));
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(() => { });
it("should not error when trying to delete from non-existent conversation/channel", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => {
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 9998,
messageId: 101
messageId: 101,
});
}).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry"));
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Message entry"),
);
expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -436,43 +517,54 @@ describe('useMessageStore', () => {
});
});
describe('Drafts', () => {
describe("Drafts", () => {
const draftKeyDirect = otherNodeNum1;
const draftKeyBroadcast = broadcastChannel;
const draftMessage = 'This is a draft';
const draftMessage = "This is a draft";
it('should set and get a draft for direct chat', () => {
it("should set and get a draft for direct chat", () => {
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(draftMessage);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(
draftMessage,
);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(
draftMessage,
);
});
it('should set and get a draft for broadcast chat', () => {
it("should set and get a draft for broadcast chat", () => {
useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(draftMessage);
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(
draftMessage,
);
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(
draftMessage,
);
});
it('should return empty string for non-existent draft', () => {
expect(useMessageStore.getState().getDraft(999)).toBe('');
it("should return empty string for non-existent draft", () => {
expect(useMessageStore.getState().getDraft(999)).toBe("");
});
it('should clear a draft', () => {
it("should clear a draft", () => {
useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true);
useMessageStore.getState().clearDraft(draftKeyDirect);
expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false);
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe('');
expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe("");
});
});
describe('deleteAllMessages', () => {
it('should clear all direct and broadcast messages, leaving empty Maps', () => {
describe("deleteAllMessages", () => {
it("should clear all direct and broadcast messages, leaving empty Maps", () => {
useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(broadcastMessage1);
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(0);
expect(useMessageStore.getState().messages.broadcast.size).toBeGreaterThan(0);
expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(
0,
);
expect(useMessageStore.getState().messages.broadcast.size)
.toBeGreaterThan(0);
useMessageStore.getState().deleteAllMessages();
@@ -483,4 +575,4 @@ describe('useMessageStore', () => {
expect(state.messages.broadcast.size).toBe(0);
});
});
});
});
+11 -11
View File
@@ -21,14 +21,14 @@ interface GenericMessage<T extends MessageType> extends MessageBase {
type: T;
}
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
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;
@@ -58,13 +58,13 @@ type ClearMessageParams =
};
export type {
Message,
ConversationId,
NodeNum,
MessageLogMap,
ChannelId,
MessageId,
GetMessagesParams,
SetMessageStateParams,
ClearMessageParams,
}
ConversationId,
GetMessagesParams,
Message,
MessageId,
MessageLogMap,
NodeNum,
SetMessageStateParams,
};
+11 -7
View File
@@ -1,4 +1,4 @@
import React, { createContext, useState, useContext, useMemo } from 'react';
import React, { createContext, useContext, useMemo, useState } from "react";
interface SidebarContextProps {
isCollapsed: boolean;
@@ -6,13 +6,17 @@ interface SidebarContextProps {
toggleSidebar: () => void;
}
const SidebarContext = createContext<SidebarContextProps | undefined>(undefined);
const SidebarContext = createContext<SidebarContextProps | undefined>(
undefined,
);
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = (
{ children },
) => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
const toggleSidebar = useMemo(() => () => {
setIsCollapsed(prev => !prev);
setIsCollapsed((prev) => !prev);
}, []);
const value = useMemo(() => ({
@@ -22,7 +26,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ child
}), [isCollapsed, toggleSidebar]);
return (
<SidebarContext.Provider value={value} >
<SidebarContext.Provider value={value}>
{children}
</SidebarContext.Provider>
);
@@ -31,7 +35,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ child
export const useSidebar = (): SidebarContextProps => {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error('useSidebar must be used within a SidebarProvider');
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
};
};
+14 -11
View File
@@ -1,5 +1,5 @@
import { PersistStorage, StateStorage, } from "zustand/middleware"; // Added StorageValue for clarity, though not strictly needed in the final signature here
import { get, set, del } from "idb-keyval";
import { PersistStorage, StateStorage } from "zustand/middleware";
import { del, get, set } from "idb-keyval";
import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts";
type PersistedMessageState = {
@@ -22,30 +22,31 @@ export const zustandIndexDBStorage: StateStorage = {
},
};
type SerializedMap<K = unknown, V = unknown> = {
__dataType: 'Map';
__dataType: "Map";
value: Array<[K, V]>;
};
// deno-lint-ignore no-explicit-any
type JsonReplacer = (this: any, key: string, value: unknown) => unknown;
const replacer: JsonReplacer = (_, value) => {
if (value instanceof Map) {
const map = value as Map<unknown, unknown>;
const serialized: SerializedMap = {
__dataType: 'Map',
__dataType: "Map",
value: Array.from(map.entries()),
};
return serialized;
}
return value;
};
// deno-lint-ignore no-explicit-any
type JsonReviver = (this: any, key: string, value: unknown) => unknown;
function isSerializedMap(value: unknown): value is SerializedMap {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const potentialMap = value as Partial<SerializedMap>;
return potentialMap.__dataType === 'Map' && Array.isArray(potentialMap.value);
return potentialMap.__dataType === "Map" && Array.isArray(potentialMap.value);
}
const reviver: JsonReviver = (_, value) => {
if (isSerializedMap(value)) {
@@ -54,11 +55,10 @@ const reviver: JsonReviver = (_, value) => {
return value;
};
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
getItem: async (name): Promise<PersistedMessageState | null> => {
const str = await zustandIndexDBStorage.getItem(name);
if (!str) { return null; }
if (!str) return null;
try {
const parsed = JSON.parse(str, reviver) as PersistedMessageState;
return parsed;
@@ -72,10 +72,13 @@ export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
const str = JSON.stringify(newValue, replacer);
await zustandIndexDBStorage.setItem(name, str);
} catch (error) {
console.error(`Error stringifying or setting persisted state (${name}):`, error);
console.error(
`Error stringifying or setting persisted state (${name}):`,
error,
);
}
},
removeItem: async (name): Promise<void> => {
await zustandIndexDBStorage.removeItem(name);
},
};
};
+10 -7
View File
@@ -1,13 +1,13 @@
import type { Device } from "@core/stores/deviceStore.ts";
import { MeshDevice, Protobuf } from "@meshtastic/core";
import { MessageState, MessageType, type MessageStore } from "./stores/messageStore/index.ts";
import { type MessageStore, MessageType } from "./stores/messageStore/index.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
export const subscribeAll = (
device: Device,
connection: MeshDevice,
messageStore: MessageStore
messageStore: MessageStore,
) => {
let myNodeNum = 0;
@@ -79,7 +79,6 @@ export const subscribeAll = (
device.setModuleConfig(moduleConfig);
});
connection.events.onMessagePacket.subscribe((messagePacket) => {
// incoming and outgoing messages are handled by this event listener
console.log("Message Packet", messagePacket);
@@ -117,7 +116,6 @@ export const subscribeAll = (
});
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {
@@ -126,19 +124,24 @@ export const subscribeAll = (
break;
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
device.setNodeError(
routingPacket.from,
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
);
device.setDialogOpen("refreshKeys", true);
break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
device.setNodeError(
routingPacket.from,
Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value],
);
device.setDialogOpen("refreshKeys", true);
break;
default: {
break;
}
}
}
});
};
+2 -2
View File
@@ -1,10 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "@core/utils/eventBus.ts";
describe("EventBus", () => {
beforeEach(() => {
// Reset event listeners before each test
(eventBus as any).listeners = {};
eventBus.listeners = {};
});
it("should register an event listener and trigger it on emit", () => {
+10 -8
View File
@@ -1,8 +1,7 @@
export type EventMap = {
'dialog:unsafeRoles': {
action: 'confirm' | 'dismiss';
"dialog:unsafeRoles": {
action: "confirm" | "dismiss";
};
// add more events as required
};
export type EventName = keyof EventMap;
@@ -11,12 +10,15 @@ export type EventCallback<T extends EventName> = (data: EventMap[T]) => void;
class EventBus {
private listeners: { [K in EventName]?: Array<EventCallback<K>> } = {};
public on<T extends EventName>(event: T, callback: EventCallback<T>): () => void {
public on<T extends EventName>(
event: T,
callback: EventCallback<T>,
): () => void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]?.push(callback as any);
this.listeners[event]?.push(callback);
return () => {
this.off(event, callback);
@@ -26,7 +28,7 @@ class EventBus {
public off<T extends EventName>(event: T, callback: EventCallback<T>): void {
if (!this.listeners[event]) return;
const callbackIndex = this.listeners[event]?.indexOf(callback as any);
const callbackIndex = this.listeners[event]?.indexOf(callback);
if (callbackIndex !== undefined && callbackIndex > -1) {
this.listeners[event]?.splice(callbackIndex, 1);
}
@@ -35,10 +37,10 @@ class EventBus {
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
if (!this.listeners[event]) return;
this.listeners[event]?.forEach(callback => {
this.listeners[event]?.forEach((callback) => {
callback(data);
});
}
}
export const eventBus = new EventBus();
export const eventBus = new EventBus();
-1
View File
@@ -63,7 +63,6 @@ export default function newGithubIssueUrl(
function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
const repoUrl = options.repoUrl ??
(options.user && options.repo
? `https://github.com/${options.user}/${options.repo}`
: undefined);
+3 -2
View File
@@ -1,6 +1,7 @@
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 | undefined {
+16 -7
View File
@@ -35,10 +35,18 @@ export interface LengthValidationResult {
currentLength: number | null;
}
export function validateMaxByteLength(value: string | null | undefined, maxByteLength: number): LengthValidationResult {
export function validateMaxByteLength(
value: string | null | undefined,
maxByteLength: number,
): LengthValidationResult {
// Ensure maxByteLength is valid
if (typeof maxByteLength !== 'number' || !Number.isInteger(maxByteLength) || maxByteLength < 0) {
console.warn('validateMaxByteLength: maxByteLength must be a non-negative integer.');
if (
typeof maxByteLength !== "number" || !Number.isInteger(maxByteLength) ||
maxByteLength < 0
) {
console.warn(
"validateMaxByteLength: maxByteLength must be a non-negative integer.",
);
return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
}
@@ -48,8 +56,10 @@ export function validateMaxByteLength(value: string | null | undefined, maxByteL
}
// Check for TextEncoder availability
if (typeof TextEncoder === 'undefined') {
console.error('validateMaxByteLength: TextEncoder API is not available in this environment.');
if (typeof TextEncoder === "undefined") {
console.error(
"validateMaxByteLength: TextEncoder API is not available in this environment.",
);
return { isValid: false, currentLength: null }; // Cannot determine byte length
}
@@ -64,8 +74,7 @@ export function validateMaxByteLength(value: string | null | undefined, maxByteL
return { isValid, currentLength };
} catch (error) {
// Handle potential errors during encoding
console.error('validateMaxByteLength: Error encoding string:', error);
console.error("validateMaxByteLength: Error encoding string:", error);
return { isValid: false, currentLength: null }; // Encoding failed
}
}