Merge branch 'master' into unread-counts
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// taken from https://react-hooked.vercel.app/docs/useLocalStorage/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface WindowEventMap {
|
||||
"local-storage": CustomEvent;
|
||||
}
|
||||
}
|
||||
|
||||
type UseLocalStorageOptions<T> = {
|
||||
serializer?: (value: T) => string;
|
||||
deserializer?: (value: string) => T;
|
||||
initializeWithValue?: boolean;
|
||||
};
|
||||
|
||||
const IS_SERVER = typeof window === "undefined";
|
||||
|
||||
/**
|
||||
* Hook for persisting state to localStorage.
|
||||
*
|
||||
* @param {string} key - The key to use for localStorage.
|
||||
* @param {T | (() => T)} initialValue - The initial value to use, if not found in localStorage.
|
||||
* @param {UseLocalStorageOptions<T>} options - Options for the hook.
|
||||
* @returns A tuple of [storedValue, setValue, removeValue].
|
||||
*/
|
||||
export default function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T | (() => T),
|
||||
options: UseLocalStorageOptions<T> = {},
|
||||
): [T, Dispatch<SetStateAction<T>>, () => void] {
|
||||
const { initializeWithValue = true } = options;
|
||||
|
||||
const serializer = useCallback<(value: T) => string>(
|
||||
(value) => {
|
||||
if (options.serializer) {
|
||||
return options.serializer(value);
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const deserializer = useCallback<(value: string) => T>(
|
||||
(value) => {
|
||||
if (options.deserializer) {
|
||||
return options.deserializer(value);
|
||||
}
|
||||
// Support 'undefined' as a value
|
||||
if (value === "undefined") {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
const defaultValue =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON:", error);
|
||||
return defaultValue; // Return initialValue if parsing fails
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
},
|
||||
[options, initialValue],
|
||||
);
|
||||
|
||||
// Get from local storage then
|
||||
// parse stored json or return initialValue
|
||||
const readValue = useCallback((): T => {
|
||||
const initialValueToUse =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
|
||||
// Prevent build error "window is undefined" but keep working
|
||||
if (IS_SERVER) {
|
||||
return initialValueToUse;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? deserializer(raw) : initialValueToUse;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key “${key}”:`, error);
|
||||
return initialValueToUse;
|
||||
}
|
||||
}, [initialValue, key, deserializer]);
|
||||
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
if (initializeWithValue) {
|
||||
return readValue();
|
||||
}
|
||||
|
||||
return initialValue instanceof Function ? initialValue() : initialValue;
|
||||
});
|
||||
|
||||
// Return a wrapped version of useState's setter function that ...
|
||||
// ... persists the new value to localStorage.
|
||||
const setValue: Dispatch<SetStateAction<T>> = useCallback(
|
||||
(value) => {
|
||||
// Prevent build error "window is undefined" but keeps working
|
||||
if (IS_SERVER) {
|
||||
console.warn(
|
||||
`Tried setting localStorage key “${key}” even though environment is not a client`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Allow value to be a function so we have the same API as useState
|
||||
const newValue = value instanceof Function ? value(readValue()) : value;
|
||||
|
||||
// Save to local storage
|
||||
window.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 }));
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key “${key}”:`, error);
|
||||
}
|
||||
},
|
||||
[key, serializer, readValue],
|
||||
);
|
||||
|
||||
const removeValue = useCallback(() => {
|
||||
// Prevent build error "window is undefined" but keeps working
|
||||
if (IS_SERVER) {
|
||||
console.warn(
|
||||
`Tried removing localStorage key “${key}” even though environment is not a client`,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultValue =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
|
||||
// Remove the key from local storage
|
||||
window.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 }));
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
setStoredValue(readValue());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const handleStorageChange = useCallback(
|
||||
(event: StorageEvent | CustomEvent) => {
|
||||
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
|
||||
return;
|
||||
}
|
||||
setStoredValue(readValue());
|
||||
},
|
||||
[key, readValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
addEventListener("storage", handleStorageChange);
|
||||
// this is a custom event, triggered in writeValueToLocalStorage
|
||||
addEventListener("local-storage", handleStorageChange);
|
||||
return () => {
|
||||
removeEventListener("storage", handleStorageChange);
|
||||
removeEventListener("local-storage", handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { Protobuf, Types } from "@meshtastic/core";
|
||||
import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
||||
import { produce } from "immer";
|
||||
import { createContext, useContext } from "react";
|
||||
import { create as createStore } from "zustand";
|
||||
@@ -26,7 +26,12 @@ export type DialogVariant =
|
||||
| "deviceName"
|
||||
| "nodeRemoval"
|
||||
| "pkiBackup"
|
||||
| "nodeDetails";
|
||||
| "nodeDetails"
|
||||
| "unsafeRoles";
|
||||
|
||||
type QueueStatus = {
|
||||
res: number, free: number, maxlen: number
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
@@ -47,13 +52,15 @@ export interface Device {
|
||||
number,
|
||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||
>;
|
||||
connection?: Types.ConnectionType;
|
||||
connection?: MeshDevice;
|
||||
activePage: Page;
|
||||
activeNode: number;
|
||||
waypoints: Protobuf.Mesh.Waypoint[];
|
||||
// currentMetrics: Protobuf.DeviceMetrics;
|
||||
pendingSettingsChanges: boolean;
|
||||
messageDraft: string;
|
||||
queueStatus: QueueStatus,
|
||||
isQueueingMessages: boolean,
|
||||
dialog: {
|
||||
import: boolean;
|
||||
QR: boolean;
|
||||
@@ -63,9 +70,11 @@ export interface Device {
|
||||
nodeRemoval: boolean;
|
||||
pkiBackup: boolean;
|
||||
nodeDetails: boolean;
|
||||
unsafeRoles: boolean;
|
||||
};
|
||||
unreadCounts: Map<number, number>;
|
||||
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||
setConfig: (config: Protobuf.Config.Config) => void;
|
||||
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||
@@ -81,7 +90,7 @@ export interface Device {
|
||||
addNodeInfo: (nodeInfo: Protobuf.Mesh.NodeInfo) => void;
|
||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||
addConnection: (connection: Types.ConnectionType) => void;
|
||||
addConnection: (connection: MeshDevice) => void;
|
||||
addMessage: (message: MessageWithState) => void;
|
||||
addTraceRoute: (
|
||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||
@@ -97,9 +106,11 @@ export interface Device {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface DeviceState {
|
||||
@@ -139,6 +150,10 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
activePage: "messages",
|
||||
activeNode: 0,
|
||||
waypoints: [],
|
||||
queueStatus: {
|
||||
res: 0, free: 0, maxlen: 0
|
||||
},
|
||||
isQueueingMessages: false,
|
||||
dialog: {
|
||||
import: false,
|
||||
QR: false,
|
||||
@@ -148,6 +163,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
nodeRemoval: false,
|
||||
pkiBackup: false,
|
||||
nodeDetails: false,
|
||||
unsafeRoles: false,
|
||||
},
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
@@ -306,7 +322,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
.findIndex(
|
||||
(wmc) =>
|
||||
wmc.payloadVariant.case ===
|
||||
moduleConfig.payloadVariant.case,
|
||||
moduleConfig.payloadVariant.case,
|
||||
);
|
||||
if (workingModuleConfigIndex !== -1) {
|
||||
device.workingModuleConfig[workingModuleConfigIndex] =
|
||||
@@ -519,7 +535,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
console.log("addTraceRoute called");
|
||||
console.log(traceroute);
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
@@ -597,6 +612,13 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
getDialogOpen: (dialog: DialogVariant) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
throw new Error("Device not found");
|
||||
}
|
||||
return device.dialog[dialog];
|
||||
},
|
||||
processPacket(data: ProcessPacketParams) {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
@@ -647,7 +669,18 @@ 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
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Device } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf, type Types } from "@meshtastic/core";
|
||||
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
||||
|
||||
|
||||
export const subscribeAll = (
|
||||
device: Device,
|
||||
connection: Types.ConnectionType,
|
||||
connection: MeshDevice,
|
||||
) => {
|
||||
let myNodeNum = 0;
|
||||
|
||||
@@ -71,6 +71,8 @@ export const subscribeAll = (
|
||||
});
|
||||
|
||||
connection.events.onChannelPacket.subscribe((channel) => {
|
||||
console.log('channel', channel);
|
||||
|
||||
device.addChannel(channel);
|
||||
});
|
||||
connection.events.onConfigPacket.subscribe((config) => {
|
||||
@@ -81,6 +83,9 @@ export const subscribeAll = (
|
||||
});
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
|
||||
console.log('messagePacket', messagePacket);
|
||||
|
||||
device.addMessage({
|
||||
...messagePacket,
|
||||
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
|
||||
@@ -105,4 +110,11 @@ export const subscribeAll = (
|
||||
time: meshPacket.rxTime,
|
||||
});
|
||||
});
|
||||
|
||||
connection.events.onQueueStatus.subscribe((queueStatus) => {
|
||||
device.setQueueStatus(queueStatus);
|
||||
if (queueStatus.free < 10) {
|
||||
// start queueing messages
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { eventBus } from "@core/utils/eventBus.ts";
|
||||
|
||||
describe("EventBus", () => {
|
||||
beforeEach(() => {
|
||||
// Reset event listeners before each test
|
||||
(eventBus as any).listeners = {};
|
||||
});
|
||||
|
||||
it("should register an event listener and trigger it on emit", () => {
|
||||
const mockCallback = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith({ action: "confirm" });
|
||||
});
|
||||
|
||||
it("should remove an event listener with off", () => {
|
||||
const mockCallback = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return an unsubscribe function from on", () => {
|
||||
const mockCallback = vi.fn();
|
||||
const unsubscribe = eventBus.on("dialog:unsafeRoles", mockCallback);
|
||||
|
||||
unsubscribe();
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow multiple listeners for the same event", () => {
|
||||
const mockCallback1 = vi.fn();
|
||||
const mockCallback2 = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback2);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback1).toHaveBeenCalledWith({ action: "confirm" });
|
||||
expect(mockCallback2).toHaveBeenCalledWith({ action: "confirm" });
|
||||
});
|
||||
|
||||
it("should only remove the specific listener when off is called", () => {
|
||||
const mockCallback1 = vi.fn();
|
||||
const mockCallback2 = vi.fn();
|
||||
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.on("dialog:unsafeRoles", mockCallback2);
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback1);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "dismiss" });
|
||||
|
||||
expect(mockCallback1).not.toHaveBeenCalled();
|
||||
expect(mockCallback2).toHaveBeenCalledWith({ action: "dismiss" });
|
||||
});
|
||||
|
||||
it("should not fail when calling off on a non-existent listener", () => {
|
||||
const mockCallback = vi.fn();
|
||||
eventBus.off("dialog:unsafeRoles", mockCallback);
|
||||
eventBus.emit("dialog:unsafeRoles", { action: "confirm" });
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled(); // No error should occur
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
export type EventMap = {
|
||||
'dialog:unsafeRoles': {
|
||||
action: 'confirm' | 'dismiss';
|
||||
};
|
||||
// add more events as required
|
||||
};
|
||||
|
||||
export type EventName = keyof EventMap;
|
||||
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 {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
|
||||
this.listeners[event]?.push(callback as any);
|
||||
|
||||
return () => {
|
||||
this.off(event, callback);
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
if (callbackIndex !== undefined && callbackIndex > -1) {
|
||||
this.listeners[event]?.splice(callbackIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public emit<T extends EventName>(event: T, data: EventMap[T]): void {
|
||||
if (!this.listeners[event]) return;
|
||||
|
||||
this.listeners[event]?.forEach(callback => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
Reference in New Issue
Block a user