Remove duplicate node logic, UI Update, flatten build output (#586)
* refactor nodes to getNodes fn. ui updates * fixed several styling issues * fix: message specific styling/overflow * added footer, fixed tests. styling * fix: added theme support back to app component * fix: hide emojis/reactions * fix: added more padding to content element * fix: fixed padding in content element * updated color scheme * fix: more dark mode styling improvements * fix: padding and alignment fixes * fix: prevent left sidebar collapse, added battery component * fix: change scrollbars to "tiny" style, improved message scrolling, fixed bug with message input * message store fixes, ui fixes * fix: disabled message persistance until after release
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
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 userId = `!${userIdHex}`;
|
||||
const last4 = userIdHex.slice(-4);
|
||||
const longName = `Meshtastic ${last4}`;
|
||||
const shortName = last4;
|
||||
const hwModel = Protobuf.Mesh.HardwareModel.UNSET;
|
||||
|
||||
return create(Protobuf.Mesh.UserSchema, {
|
||||
id: userId,
|
||||
longName: longName,
|
||||
shortName: shortName,
|
||||
hwModel: hwModel,
|
||||
isLicensed: false,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
return node;
|
||||
}
|
||||
|
||||
node.user = NodeInfoFactory.createDefaultUser(node.num);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
export default NodeInfoFactory;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Types } from "@meshtastic/js";
|
||||
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts";
|
||||
import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts";
|
||||
|
||||
class PacketToMessageDTO {
|
||||
channel: Types.ChannelNumber;
|
||||
|
||||
+115
-255
@@ -6,31 +6,13 @@ import { create as createStore } from "zustand";
|
||||
|
||||
export type Page = "messages" | "map" | "config" | "channels" | "nodes";
|
||||
|
||||
export interface MessageWithState extends Types.PacketMetadata<string> {
|
||||
state: MessageState;
|
||||
}
|
||||
|
||||
export type MessageState = "ack" | "waiting" | 'failed';
|
||||
|
||||
export interface ProcessPacketParams {
|
||||
from: number;
|
||||
snr: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export type DialogVariant =
|
||||
| "import"
|
||||
| "QR"
|
||||
| "shutdown"
|
||||
| "reboot"
|
||||
| "rebootOTA"
|
||||
| "deviceName"
|
||||
| "nodeRemoval"
|
||||
| "pkiBackup"
|
||||
| "nodeDetails"
|
||||
| "unsafeRoles"
|
||||
| "refreshKeys"
|
||||
| "deleteMessages";
|
||||
export type DialogVariant = keyof Device["dialog"];
|
||||
|
||||
type NodeError = {
|
||||
node: number;
|
||||
@@ -46,7 +28,6 @@ export interface Device {
|
||||
workingConfig: Protobuf.Config.Config[];
|
||||
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
|
||||
hardware: Protobuf.Mesh.MyNodeInfo;
|
||||
nodes: Map<number, Protobuf.Mesh.NodeInfo>;
|
||||
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
||||
traceroutes: Map<
|
||||
number,
|
||||
@@ -57,10 +38,10 @@ export interface Device {
|
||||
activePage: Page;
|
||||
activeNode: number;
|
||||
waypoints: Protobuf.Mesh.Waypoint[];
|
||||
// currentMetrics: Protobuf.DeviceMetrics;
|
||||
pendingSettingsChanges: boolean;
|
||||
messageDraft: string;
|
||||
unreadCounts: Map<number, number>;
|
||||
nodesMap: Map<number, Protobuf.Mesh.NodeInfo>; // dont access directly, use getNodes, or getNode
|
||||
dialog: {
|
||||
import: boolean;
|
||||
QR: boolean;
|
||||
@@ -76,14 +57,12 @@ export interface Device {
|
||||
deleteMessages: boolean;
|
||||
};
|
||||
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||
setConfig: (config: Protobuf.Config.Config) => void;
|
||||
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||
setWorkingConfig: (config: Protobuf.Config.Config) => void;
|
||||
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
|
||||
// setMetrics: (metrics: Types.PacketMetadata<Protobuf.Telemetry>) => void;
|
||||
setActivePage: (page: Page) => void;
|
||||
setActiveNode: (node: number) => void;
|
||||
setPendingSettingsChanges: (state: boolean) => void;
|
||||
@@ -105,9 +84,15 @@ export interface Device {
|
||||
setNodeError: (nodeNum: number, error: string) => void;
|
||||
clearNodeError: (nodeNum: number) => void;
|
||||
getNodeError: (nodeNum: number) => NodeError | undefined;
|
||||
hasNodeError: (nodeNum: number) => boolean
|
||||
hasNodeError: (nodeNum: number) => boolean;
|
||||
incrementUnread: (nodeNum: number) => void;
|
||||
resetUnread: (nodeNum: number) => void;
|
||||
getNodes: (
|
||||
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
|
||||
) => Protobuf.Mesh.NodeInfo[];
|
||||
getNodesLength: () => number;
|
||||
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
|
||||
|
||||
}
|
||||
|
||||
export interface DeviceState {
|
||||
@@ -125,6 +110,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
remoteDevices: new Map(),
|
||||
|
||||
addDevice: (id: number) => {
|
||||
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
draft.devices.set(id, {
|
||||
@@ -136,7 +122,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
workingConfig: [],
|
||||
workingModuleConfig: [],
|
||||
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
||||
nodes: new Map(),
|
||||
metadata: new Map(),
|
||||
traceroutes: new Map(),
|
||||
connection: undefined,
|
||||
@@ -161,7 +146,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
messageDraft: "",
|
||||
nodeErrors: new Map(),
|
||||
unreadCounts: new Map(),
|
||||
|
||||
nodesMap: new Map(),
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => {
|
||||
set(
|
||||
@@ -177,40 +162,16 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -220,66 +181,20 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -289,40 +204,30 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const workingConfigIndex = device?.workingConfig.findIndex(
|
||||
if (!device) return;
|
||||
const index = device.workingConfig.findIndex(
|
||||
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
|
||||
);
|
||||
if (workingConfigIndex !== -1) {
|
||||
device.workingConfig[workingConfigIndex] = config;
|
||||
if (index !== -1) {
|
||||
device.workingConfig[index] = config;
|
||||
} else {
|
||||
device?.workingConfig.push(config);
|
||||
device.workingConfig.push(config);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
setWorkingModuleConfig: (
|
||||
moduleConfig: Protobuf.ModuleConfig.ModuleConfig,
|
||||
) => {
|
||||
setWorkingModuleConfig: (moduleConfig: Protobuf.ModuleConfig.ModuleConfig) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const workingModuleConfigIndex = device?.workingModuleConfig
|
||||
.findIndex(
|
||||
(wmc) =>
|
||||
wmc.payloadVariant.case ===
|
||||
moduleConfig.payloadVariant.case,
|
||||
);
|
||||
if (workingModuleConfigIndex !== -1) {
|
||||
device.workingModuleConfig[workingModuleConfigIndex] =
|
||||
moduleConfig;
|
||||
if (!device) return;
|
||||
const index = device.workingModuleConfig.findIndex(
|
||||
(wmc) => wmc.payloadVariant.case === moduleConfig.payloadVariant.case,
|
||||
);
|
||||
if (index !== -1) {
|
||||
device.workingModuleConfig[index] = moduleConfig;
|
||||
} else {
|
||||
device?.workingModuleConfig.push(moduleConfig);
|
||||
device.workingModuleConfig.push(moduleConfig);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -337,50 +242,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
// setMetrics: (metrics: Types.PacketMetadata<Protobuf.Telemetry>) => {
|
||||
// set(
|
||||
// produce<DeviceState>((draft) => {
|
||||
// const device = draft.devices.get(id);
|
||||
// let node = device?.nodes.find(
|
||||
// (n) => n.data.num === metrics.from
|
||||
// );
|
||||
// if (node) {
|
||||
// switch (metrics.data.variant.case) {
|
||||
// case "deviceMetrics":
|
||||
// if (device) {
|
||||
// if (metrics.data.variant.value.batteryLevel) {
|
||||
// device.currentMetrics.batteryLevel =
|
||||
// metrics.data.variant.value.batteryLevel;
|
||||
// }
|
||||
// if (metrics.data.variant.value.voltage) {
|
||||
// device.currentMetrics.voltage =
|
||||
// metrics.data.variant.value.voltage;
|
||||
// }
|
||||
// if (metrics.data.variant.value.airUtilTx) {
|
||||
// device.currentMetrics.airUtilTx =
|
||||
// metrics.data.variant.value.airUtilTx;
|
||||
// }
|
||||
// if (metrics.data.variant.value.channelUtilization) {
|
||||
// device.currentMetrics.channelUtilization =
|
||||
// metrics.data.variant.value.channelUtilization;
|
||||
// }
|
||||
// }
|
||||
// node.deviceMetrics.push({
|
||||
// metric: metrics.data.variant.value,
|
||||
// timestamp: metrics.rxTime
|
||||
// });
|
||||
// break;
|
||||
// case "environmentMetrics":
|
||||
// node.environmentMetrics.push({
|
||||
// metric: metrics.data.variant.value,
|
||||
// timestamp: metrics.rxTime
|
||||
// });
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// );
|
||||
// },
|
||||
setActivePage: (page) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
@@ -405,10 +266,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
if (device) {
|
||||
device.channels.set(channel.index, channel);
|
||||
}
|
||||
device.channels.set(channel.index, channel);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -417,12 +277,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (device) {
|
||||
const waypointIndex = device.waypoints.findIndex(
|
||||
(wp) => wp.id === waypoint.id,
|
||||
);
|
||||
|
||||
if (waypointIndex !== -1) {
|
||||
device.waypoints[waypointIndex] = waypoint;
|
||||
const index = device.waypoints.findIndex((wp) => wp.id === waypoint.id);
|
||||
if (index !== -1) {
|
||||
device.waypoints[index] = waypoint;
|
||||
} else {
|
||||
device.waypoints.push(waypoint);
|
||||
}
|
||||
@@ -431,13 +288,14 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
);
|
||||
},
|
||||
addNodeInfo: (nodeInfo) => {
|
||||
console.log("Node Info", nodeInfo);
|
||||
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
device.nodes.set(nodeInfo.num, nodeInfo);
|
||||
|
||||
if (!device) return;
|
||||
device.nodesMap.set(nodeInfo.num, nodeInfo);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -458,11 +316,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const currentNode = device.nodes.get(user.from) ??
|
||||
create(Protobuf.Mesh.NodeInfoSchema);
|
||||
const currentNode = device.nodesMap.get(user.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
|
||||
currentNode.user = user.data;
|
||||
device.nodes.set(user.from, currentNode);
|
||||
}),
|
||||
currentNode.num = user.from;
|
||||
device.nodesMap.set(user.from, currentNode);
|
||||
})
|
||||
);
|
||||
},
|
||||
addPosition: (position) => {
|
||||
@@ -472,11 +330,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const currentNode = device.nodes.get(position.from) ??
|
||||
create(Protobuf.Mesh.NodeInfoSchema);
|
||||
const currentNode = device.nodesMap.get(position.from) ?? create(Protobuf.Mesh.NodeInfoSchema);
|
||||
currentNode.position = position.data;
|
||||
device.nodes.set(position.from, currentNode);
|
||||
}),
|
||||
currentNode.num = position.from;
|
||||
device.nodesMap.set(position.from, currentNode);
|
||||
})
|
||||
);
|
||||
},
|
||||
addConnection: (connection) => {
|
||||
@@ -493,10 +351,9 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
if (device) {
|
||||
device.metadata.set(from, metadata);
|
||||
}
|
||||
device.metadata.set(from, metadata);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -504,17 +361,10 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodetraceroutes = device.traceroutes.get(traceroute.from);
|
||||
if (nodetraceroutes) {
|
||||
nodetraceroutes.push(traceroute);
|
||||
device.traceroutes.set(traceroute.from, nodetraceroutes);
|
||||
} else {
|
||||
device.traceroutes.set(traceroute.from, [traceroute]);
|
||||
}
|
||||
if (!device) return;
|
||||
const routes = device.traceroutes.get(traceroute.from) ?? [];
|
||||
routes.push(traceroute);
|
||||
device.traceroutes.set(traceroute.from, routes);
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -523,46 +373,38 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
device.nodes.delete(nodeNum);
|
||||
}),
|
||||
);
|
||||
device.nodesMap.delete(nodeNum);
|
||||
}))
|
||||
},
|
||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
if (device) {
|
||||
device.dialog[dialog] = open;
|
||||
}
|
||||
device.dialog[dialog] = open;
|
||||
}),
|
||||
);
|
||||
},
|
||||
getDialogOpen: (dialog: DialogVariant) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
throw new Error("Device not found");
|
||||
}
|
||||
if (!device) throw new Error(`Device ${id} not found`);
|
||||
return device.dialog[dialog];
|
||||
},
|
||||
processPacket(data: ProcessPacketParams) {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const node = device.nodes.get(data.from);
|
||||
if (!device) return;
|
||||
const node = device.nodesMap.get(data.from);
|
||||
if (node) {
|
||||
device.nodes.set(data.from, {
|
||||
...node,
|
||||
lastHeard: data.time,
|
||||
snr: data.snr,
|
||||
});
|
||||
node.lastHeard = data.time;
|
||||
node.snr = data.snr;
|
||||
device.nodesMap.set(data.from, node);
|
||||
} else {
|
||||
device.nodes.set(
|
||||
device.nodesMap.set(
|
||||
data.from,
|
||||
create(Protobuf.Mesh.NodeInfoSchema, {
|
||||
num: data.from,
|
||||
@@ -606,26 +448,19 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
},
|
||||
getNodeError: (nodeNum: number) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
throw new Error("Device not found");
|
||||
}
|
||||
if (!device) throw new Error(`Device ${id} not found`);
|
||||
return device.nodeErrors.get(nodeNum);
|
||||
},
|
||||
hasNodeError: (nodeNum: number) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
throw new Error("Device not found");
|
||||
}
|
||||
if (!device) throw new Error(`Device ${id} not found`);
|
||||
return device.nodeErrors.has(nodeNum);
|
||||
},
|
||||
incrementUnread: (nodeNum: number) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
console.warn(`incrementUnread: Device with ID ${id} not found.`);
|
||||
return;
|
||||
}
|
||||
if (!device) return;
|
||||
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
|
||||
device.unreadCounts.set(nodeNum, currentCount + 1);
|
||||
})
|
||||
@@ -635,29 +470,54 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
console.warn(`resetUnread: Device with ID ${id} not found.`);
|
||||
return;
|
||||
}
|
||||
if (!device) return;
|
||||
device.unreadCounts.set(nodeNum, 0);
|
||||
|
||||
if (device.unreadCounts.get(nodeNum) === 0) {
|
||||
device.unreadCounts.delete(nodeNum);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
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);
|
||||
if (filter) {
|
||||
return allNodes.filter(filter);
|
||||
}
|
||||
return allNodes;
|
||||
},
|
||||
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
if (!device.nodesMap.has(nodeNum)) {
|
||||
return undefined;
|
||||
}
|
||||
return device.nodesMap.get(nodeNum);
|
||||
},
|
||||
getNodesLength: () => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
return 0;
|
||||
}
|
||||
return device.nodesMap.size
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const device = get().devices.get(id);
|
||||
|
||||
if (!device) {
|
||||
throw new Error("Device not found");
|
||||
throw new Error(`Failed to create or retrieve device with ID ${id}`);
|
||||
}
|
||||
return device;
|
||||
},
|
||||
|
||||
removeDevice: (id) => {
|
||||
set(
|
||||
produce<DeviceState>((draft) => {
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
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', () => {
|
||||
return {
|
||||
zustandIndexDBStorage: {
|
||||
getItem: vi.fn(async (name: string): Promise<string | null> => {
|
||||
return memoryStorage[name] ?? null;
|
||||
}),
|
||||
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
|
||||
memoryStorage[name] = value;
|
||||
}),
|
||||
removeItem: vi.fn(async (name: string): Promise<void> => {
|
||||
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 combined direct messages when myNodeNum and otherNodeNum are provided', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum, // Keep this
|
||||
otherNodeNum: otherNodeNum1
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(directMessageToOther1);
|
||||
expect(messages[1]).toEqual(directMessageFromOther1);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
from: myNodeNum,
|
||||
to: 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,
|
||||
from: otherNodeNum1,
|
||||
to: 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 to/from/channel objects', () => {
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, from: otherNodeNum1, to: 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('deleteAllMessages', () => {
|
||||
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().deleteAllMessages();
|
||||
|
||||
expect(useMessageStore.getState().messages.direct).toEqual({});
|
||||
expect(useMessageStore.getState().messages.broadcast).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
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;
|
||||
deleteAllMessages: () => void;
|
||||
clearMessageByMessageId: (params: {
|
||||
type: MessageType;
|
||||
from?: number;
|
||||
to?: 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, from, to, 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 && from !== undefined && to !== undefined) {
|
||||
const messageMap = state.messages.direct?.[from]?.[to];
|
||||
if (messageMap?.[messageId]) {
|
||||
delete messageMap[messageId];
|
||||
if (Object.keys(messageMap).length === 0) {
|
||||
delete state.messages.direct[from][to];
|
||||
if (Object.keys(state.messages.direct[from]).length === 0) {
|
||||
delete state.messages.direct[from];
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}));
|
||||
},
|
||||
deleteAllMessages: () => {
|
||||
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,212 @@
|
||||
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",
|
||||
Waiting = "waiting",
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
export enum MessageType {
|
||||
Direct = "direct",
|
||||
Broadcast = "broadcast",
|
||||
}
|
||||
|
||||
export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
|
||||
return [node1, node2].sort((a, b) => a - b).join(':');
|
||||
}
|
||||
|
||||
export interface MessageStore {
|
||||
messages: {
|
||||
direct: Map<ConversationId, MessageLogMap>;
|
||||
broadcast: Map<ChannelId, MessageLogMap>;
|
||||
};
|
||||
};
|
||||
export interface MessageStore {
|
||||
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
|
||||
chatType: MessageType;
|
||||
|
||||
setNodeNum: (nodeNum: number) => void;
|
||||
getMyNodeNum: () => number;
|
||||
setActiveChat: (chat: number) => void;
|
||||
setChatType: (type: MessageType) => void;
|
||||
saveMessage: (message: Message) => void;
|
||||
setMessageState: (params: SetMessageStateParams) => void;
|
||||
getMessages: (params: GetMessagesParams) => Message[];
|
||||
getDraft: (key: Types.Destination) => string;
|
||||
setDraft: (key: Types.Destination, message: string) => void;
|
||||
deleteAllMessages: () => void;
|
||||
clearMessageByMessageId: (params: ClearMessageParams) => void;
|
||||
clearDraft: (key: Types.Destination) => void;
|
||||
}
|
||||
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
|
||||
export const useMessageStore = create<MessageStore>()(
|
||||
// persist(
|
||||
(set, get) => ({
|
||||
messages: {
|
||||
direct: new Map<ConversationId, MessageLogMap>(),
|
||||
broadcast: new Map<ChannelId, MessageLogMap>(),
|
||||
},
|
||||
draft: new Map<number, string>(),
|
||||
activeChat: 0,
|
||||
chatType: MessageType.Broadcast,
|
||||
nodeNum: 0,
|
||||
setNodeNum: (nodeNum) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.nodeNum = nodeNum;
|
||||
}));
|
||||
},
|
||||
getMyNodeNum: () => get().nodeNum,
|
||||
setActiveChat: (chat) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.activeChat = chat;
|
||||
}));
|
||||
},
|
||||
setChatType: (type) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.chatType = type;
|
||||
}));
|
||||
},
|
||||
saveMessage: (message: Message) => {
|
||||
set(
|
||||
produce((state: 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.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.get(channelId)!.set(message.messageId, message);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
setMessageState: (params: SetMessageStateParams) => {
|
||||
set(
|
||||
produce((state: MessageStore) => {
|
||||
let messageLog: MessageLogMap | undefined;
|
||||
let targetMessage: Message | undefined;
|
||||
|
||||
if (params.type === MessageType.Direct) {
|
||||
const conversationId = getConversationId(params.nodeA, params.nodeB);
|
||||
messageLog = state.messages.direct.get(conversationId);
|
||||
if (messageLog) {
|
||||
targetMessage = messageLog.get(params.messageId);
|
||||
}
|
||||
} else { // Broadcast
|
||||
messageLog = state.messages.broadcast.get(params.channelId);
|
||||
if (messageLog) {
|
||||
targetMessage = messageLog.get(params.messageId);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetMessage) {
|
||||
targetMessage.state = params.newState ?? MessageState.Ack;
|
||||
} else {
|
||||
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
getMessages: (params: GetMessagesParams): Message[] => {
|
||||
const state = get();
|
||||
let messageMap: MessageLogMap | undefined;
|
||||
|
||||
if (params.type === MessageType.Direct) {
|
||||
const conversationId = getConversationId(params.nodeA, params.nodeB);
|
||||
messageMap = state.messages.direct.get(conversationId);
|
||||
|
||||
} else {
|
||||
messageMap = state.messages.broadcast.get(params.channelId);
|
||||
}
|
||||
|
||||
if (messageMap === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const messagesArray = Array.from(messageMap.values());
|
||||
messagesArray.sort((a, b) => a.date - b.date);
|
||||
return messagesArray;
|
||||
},
|
||||
|
||||
clearMessageByMessageId: (params: ClearMessageParams) => {
|
||||
set(
|
||||
produce((state: MessageStore) => {
|
||||
let messageLog: MessageLogMap | undefined;
|
||||
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
|
||||
let parentKey: ConversationId | ChannelId;
|
||||
|
||||
if (params.type === MessageType.Direct) {
|
||||
parentKey = getConversationId(params.nodeA, params.nodeB);
|
||||
parentMap = state.messages.direct;
|
||||
messageLog = parentMap.get(parentKey);
|
||||
} else {
|
||||
parentKey = params.channelId;
|
||||
parentMap = state.messages.broadcast;
|
||||
messageLog = parentMap.get(parentKey);
|
||||
}
|
||||
|
||||
if (messageLog) {
|
||||
const deleted = messageLog.delete(params.messageId);
|
||||
|
||||
if (deleted) {
|
||||
console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`);
|
||||
// Clean up empty MessageLogMap and its entry in the parent map
|
||||
if (messageLog.size === 0) {
|
||||
parentMap.delete(parentKey);
|
||||
console.log(`Cleaned up empty message entry for ${parentKey}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Message entry ${parentKey} not found for message deletion.`);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
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);
|
||||
}));
|
||||
},
|
||||
deleteAllMessages: () => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.messages.direct = new Map<ConversationId, MessageLogMap>();
|
||||
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
|
||||
}));
|
||||
}
|
||||
}),
|
||||
// {
|
||||
// name: 'meshtastic-message-store',
|
||||
// storage: storageWithMapSupport,
|
||||
// version: CURRENT_STORE_VERSION,
|
||||
// partialize: (state) => ({
|
||||
// messages: state.messages,
|
||||
// nodeNum: state.nodeNum,
|
||||
// }),
|
||||
// })
|
||||
)
|
||||
@@ -0,0 +1,486 @@
|
||||
import { describe, it, expect, beforeEach, 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';
|
||||
|
||||
vi.mock('../storage/indexDB.ts', () => {
|
||||
let memoryStorage: Record<string, string> = {};
|
||||
return {
|
||||
storageWithMapSupport: {
|
||||
getItem: vi.fn(async (name: string): Promise<string | null> => {
|
||||
return memoryStorage[name] ?? null;
|
||||
}),
|
||||
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
|
||||
memoryStorage[name] = value;
|
||||
}),
|
||||
removeItem: vi.fn(async (name: string): Promise<void> => {
|
||||
delete memoryStorage[name];
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const myNodeNum = 111;
|
||||
const otherNodeNum1 = 222;
|
||||
const otherNodeNum2 = 333;
|
||||
const broadcastChannel: ChannelId = 0;
|
||||
|
||||
const directMessageToOther1: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum1,
|
||||
channel: 0,
|
||||
date: Date.now(),
|
||||
messageId: 101,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello other 1 from me',
|
||||
};
|
||||
|
||||
const directMessageFromOther1: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: otherNodeNum1,
|
||||
to: myNodeNum,
|
||||
channel: 0,
|
||||
date: Date.now() + 1000,
|
||||
messageId: 102,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello me from other 1',
|
||||
};
|
||||
|
||||
const directMessageToOther2: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum2,
|
||||
channel: 0,
|
||||
date: Date.now() + 2000,
|
||||
messageId: 103,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello other 2 from me',
|
||||
};
|
||||
|
||||
const broadcastMessage1: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
from: otherNodeNum1,
|
||||
to: 0xffffffff,
|
||||
channel: broadcastChannel,
|
||||
date: Date.now() + 3000,
|
||||
messageId: 201,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast message 1',
|
||||
};
|
||||
|
||||
const broadcastMessage2: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
from: myNodeNum,
|
||||
to: 0xffffffff,
|
||||
channel: broadcastChannel,
|
||||
date: Date.now() + 4000,
|
||||
messageId: 202,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast message 2',
|
||||
};
|
||||
|
||||
describe('useMessageStore', () => {
|
||||
const initialState = useMessageStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageStore.setState({
|
||||
...initialState,
|
||||
messages: {
|
||||
direct: new Map<ConversationId, MessageLogMap>(),
|
||||
broadcast: new Map<ChannelId, MessageLogMap>(),
|
||||
},
|
||||
draft: new Map<Types.Destination, string>(),
|
||||
}, true);
|
||||
|
||||
});
|
||||
|
||||
it('should have correct initial state', () => {
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct).toBeInstanceOf(Map);
|
||||
expect(state.messages.direct.size).toBe(0);
|
||||
expect(state.messages.broadcast).toBeInstanceOf(Map);
|
||||
expect(state.messages.broadcast.size).toBe(0);
|
||||
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 Map structure', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
const state = useMessageStore.getState();
|
||||
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
|
||||
|
||||
// Check if the conversation Map exists
|
||||
expect(state.messages.direct.has(conversationId)).toBe(true);
|
||||
const conversationLog = state.messages.direct.get(conversationId);
|
||||
// Check if the inner Map (MessageLogMap) exists and is a Map
|
||||
expect(conversationLog).toBeInstanceOf(Map);
|
||||
// Check if the message exists within the inner Map
|
||||
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
|
||||
// Check the message content
|
||||
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
|
||||
});
|
||||
|
||||
it('should save a broadcast message with correct Map structure', () => {
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
const state = useMessageStore.getState();
|
||||
const channelId = broadcastMessage1.channel;
|
||||
|
||||
expect(state.messages.broadcast.has(channelId)).toBe(true);
|
||||
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);
|
||||
});
|
||||
|
||||
it('should save multiple messages correctly', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
|
||||
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(directMessageFromOther1.messageId)).toEqual(directMessageFromOther1);
|
||||
|
||||
const channelId = broadcastMessage1.channel;
|
||||
expect(state.messages.broadcast.get(channelId)?.get(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({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: broadcastChannel
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(broadcastMessage1);
|
||||
expect(messages[1]).toEqual(broadcastMessage2);
|
||||
});
|
||||
|
||||
it('should return empty array for broadcast if channel has no messages', () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: Types.ChannelNumber.Channel1
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return combined direct messages for a specific chat pair, sorted by date', () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(directMessageToOther1);
|
||||
expect(messages[1]).toEqual(directMessageFromOther1);
|
||||
});
|
||||
|
||||
it('should return only relevant direct messages for a different chat pair', () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum2
|
||||
});
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(directMessageToOther2);
|
||||
});
|
||||
|
||||
it('should return empty array for direct chat if no messages exist', () => {
|
||||
const messages = useMessageStore.getState().getMessages({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: 999
|
||||
});
|
||||
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', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageToOther1.from,
|
||||
nodeB: directMessageToOther1.to,
|
||||
messageId: directMessageToOther1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
|
||||
const message = useMessageStore.getState().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', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: directMessageFromOther1.from,
|
||||
nodeB: directMessageFromOther1.to,
|
||||
messageId: directMessageFromOther1.messageId,
|
||||
newState: MessageState.Failed,
|
||||
});
|
||||
const conversationId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
|
||||
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageFromOther1.messageId);
|
||||
expect(message?.state).toBe(MessageState.Failed);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('should warn if message is not found (direct)', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1,
|
||||
messageId: 999,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should warn if message is not found (broadcast)', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
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'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should warn if conversation/channel is not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: 998,
|
||||
messageId: 101,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearMessageByMessageId', () => {
|
||||
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 });
|
||||
});
|
||||
|
||||
it('should delete a specific direct message', () => {
|
||||
const messageIdToDelete = directMessageToOther1.messageId;
|
||||
const nodeA = directMessageToOther1.from;
|
||||
const nodeB = directMessageToOther1.to;
|
||||
const conversationId = getConversationId(nodeA, nodeB);
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: nodeA,
|
||||
nodeB: nodeB,
|
||||
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(state.messages.direct.has(conversationId)).toBe(true);
|
||||
|
||||
});
|
||||
|
||||
it('should delete another specific direct message', () => {
|
||||
const messageIdToDelete = directMessageFromOther1.messageId;
|
||||
const nodeA = directMessageFromOther1.from;
|
||||
const nodeB = directMessageFromOther1.to;
|
||||
const conversationId = getConversationId(nodeA, nodeB);
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: nodeA,
|
||||
nodeB: nodeB,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
const conversationLog = state.messages.direct.get(conversationId);
|
||||
expect(conversationLog?.has(messageIdToDelete)).toBe(false);
|
||||
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
|
||||
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
it('should delete a specific broadcast message', () => {
|
||||
const messageIdToDelete = broadcastMessage1.messageId;
|
||||
const channelId = broadcastMessage1.channel;
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Broadcast,
|
||||
channelId: channelId,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up empty conversation/channel Maps', () => {
|
||||
const directConvId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
|
||||
const broadcastChanId = broadcastMessage1.channel;
|
||||
|
||||
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);
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId });
|
||||
|
||||
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(() => { });
|
||||
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
|
||||
|
||||
expect(() => {
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
nodeA: myNodeNum,
|
||||
nodeB: otherNodeNum1,
|
||||
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'));
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry"));
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drafts', () => {
|
||||
const draftKeyDirect = otherNodeNum1;
|
||||
const draftKeyBroadcast = broadcastChannel;
|
||||
const draftMessage = 'This is a draft';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should return empty string for non-existent draft', () => {
|
||||
expect(useMessageStore.getState().getDraft(999)).toBe('');
|
||||
});
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
useMessageStore.getState().deleteAllMessages();
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct).toBeInstanceOf(Map);
|
||||
expect(state.messages.direct.size).toBe(0);
|
||||
expect(state.messages.broadcast).toBeInstanceOf(Map);
|
||||
expect(state.messages.broadcast.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Types } from "@meshtastic/core";
|
||||
import { MessageState, MessageType } from "@core/stores/messageStore/index.ts";
|
||||
|
||||
type NodeNum = number;
|
||||
type MessageId = number;
|
||||
type ChannelId = Types.ChannelNumber;
|
||||
type ConversationId = string;
|
||||
type MessageLogMap = Map<MessageId, Message>;
|
||||
|
||||
interface MessageBase {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: number;
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface GenericMessage<T extends MessageType> extends MessageBase {
|
||||
type: T;
|
||||
}
|
||||
|
||||
type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
|
||||
|
||||
|
||||
type GetMessagesParams =
|
||||
| { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum }
|
||||
| { type: MessageType.Broadcast; channelId: ChannelId };
|
||||
|
||||
|
||||
type SetMessageStateParams =
|
||||
| {
|
||||
type: MessageType.Direct;
|
||||
nodeA: NodeNum;
|
||||
nodeB: NodeNum;
|
||||
messageId: MessageId; // ID of the message within that chat
|
||||
newState?: MessageState; // Optional new state, defaults to Ack
|
||||
}
|
||||
| {
|
||||
type: MessageType.Broadcast;
|
||||
channelId: ChannelId;
|
||||
messageId: MessageId;
|
||||
newState?: MessageState; // Optional new state, defaults to Ack
|
||||
};
|
||||
|
||||
type ClearMessageParams =
|
||||
| {
|
||||
type: MessageType.Direct;
|
||||
nodeA: NodeNum;
|
||||
nodeB: NodeNum;
|
||||
messageId: MessageId;
|
||||
}
|
||||
| {
|
||||
type: MessageType.Broadcast;
|
||||
channelId: ChannelId;
|
||||
messageId: MessageId;
|
||||
};
|
||||
|
||||
export type {
|
||||
Message,
|
||||
ConversationId,
|
||||
NodeNum,
|
||||
MessageLogMap,
|
||||
ChannelId,
|
||||
MessageId,
|
||||
GetMessagesParams,
|
||||
SetMessageStateParams,
|
||||
ClearMessageParams,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { createContext, useState, useContext, useMemo } from 'react';
|
||||
|
||||
interface SidebarContextProps {
|
||||
isCollapsed: boolean;
|
||||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextProps | undefined>(undefined);
|
||||
|
||||
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
|
||||
|
||||
const toggleSidebar = useMemo(() => () => {
|
||||
setIsCollapsed(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
toggleSidebar,
|
||||
}), [isCollapsed, toggleSidebar]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={value} >
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSidebar = (): SidebarContextProps => {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,5 +1,14 @@
|
||||
import { StateStorage } from "zustand/middleware";
|
||||
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 { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts";
|
||||
|
||||
type PersistedMessageState = {
|
||||
messages: {
|
||||
direct: Map<string, MessageLogMap>;
|
||||
broadcast: Map<ChannelId, MessageLogMap>;
|
||||
};
|
||||
nodeNum: number;
|
||||
};
|
||||
|
||||
export const zustandIndexDBStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
@@ -11,4 +20,62 @@ export const zustandIndexDBStorage: StateStorage = {
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await del(name);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
type SerializedMap<K = unknown, V = unknown> = {
|
||||
__dataType: 'Map';
|
||||
value: Array<[K, V]>;
|
||||
};
|
||||
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',
|
||||
value: Array.from(map.entries()),
|
||||
};
|
||||
return serialized;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
const potentialMap = value as Partial<SerializedMap>;
|
||||
return potentialMap.__dataType === 'Map' && Array.isArray(potentialMap.value);
|
||||
}
|
||||
const reviver: JsonReviver = (_, value) => {
|
||||
if (isSerializedMap(value)) {
|
||||
return new Map(value.value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
|
||||
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
|
||||
getItem: async (name): Promise<PersistedMessageState | null> => {
|
||||
const str = await zustandIndexDBStorage.getItem(name);
|
||||
if (!str) { return null; }
|
||||
try {
|
||||
const parsed = JSON.parse(str, reviver) as PersistedMessageState;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing persisted state (${name}):`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: async (name, newValue: PersistedMessageState): Promise<void> => {
|
||||
try {
|
||||
const str = JSON.stringify(newValue, replacer);
|
||||
await zustandIndexDBStorage.setItem(name, str);
|
||||
} catch (error) {
|
||||
console.error(`Error stringifying or setting persisted state (${name}):`, error);
|
||||
}
|
||||
},
|
||||
removeItem: async (name): Promise<void> => {
|
||||
await zustandIndexDBStorage.removeItem(name);
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Device } from "@core/stores/deviceStore.ts";
|
||||
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
||||
import { MessageType, type MessageStore } from "@core/stores/messageStore.ts";
|
||||
import { MessageState, MessageType, type MessageStore } from "./stores/messageStore/index.ts";
|
||||
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||
import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
|
||||
|
||||
export const subscribeAll = (
|
||||
device: Device,
|
||||
@@ -10,9 +11,6 @@ export const subscribeAll = (
|
||||
) => {
|
||||
let myNodeNum = 0;
|
||||
|
||||
// onLogEvent
|
||||
// onMeshHeartbeat
|
||||
|
||||
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
|
||||
device.addMetadata(metadataPacket.from, metadataPacket.data);
|
||||
});
|
||||
@@ -59,18 +57,20 @@ export const subscribeAll = (
|
||||
});
|
||||
|
||||
connection.events.onUserPacket.subscribe((user) => {
|
||||
console.log("User Packet", user);
|
||||
|
||||
device.addUser(user);
|
||||
});
|
||||
|
||||
connection.events.onPositionPacket.subscribe((position) => {
|
||||
console.log("Position Packet", position);
|
||||
|
||||
device.addPosition(position);
|
||||
});
|
||||
|
||||
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
|
||||
// toast(`New Node Discovered: ${nodeInfo.user?.shortName ?? "UNK"}`, {
|
||||
// icon: "🔎"
|
||||
// });
|
||||
device.addNodeInfo(nodeInfo);
|
||||
const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
|
||||
device.addNodeInfo(nodeWithUser);
|
||||
});
|
||||
|
||||
connection.events.onChannelPacket.subscribe((channel) => {
|
||||
@@ -86,6 +86,8 @@ export const subscribeAll = (
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
// incoming and outgoing messages are handled by this event listener
|
||||
console.log("Message Packet", messagePacket);
|
||||
|
||||
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
|
||||
const message = dto.toMessage();
|
||||
messageStore.saveMessage(message);
|
||||
@@ -123,6 +125,9 @@ export const subscribeAll = (
|
||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||
if (routingPacket.data.variant.case === "errorReason") {
|
||||
switch (routingPacket.data.variant.value) {
|
||||
case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT:
|
||||
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
|
||||
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]);
|
||||
|
||||
@@ -29,3 +29,43 @@ export function formatQuantity(
|
||||
|
||||
return `${numberFormat.format(value)} ${word}`;
|
||||
}
|
||||
|
||||
export interface LengthValidationResult {
|
||||
isValid: boolean;
|
||||
currentLength: number | null;
|
||||
}
|
||||
|
||||
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.');
|
||||
return { isValid: false, currentLength: null }; // Cannot validate with invalid limit
|
||||
}
|
||||
|
||||
// Handle null or undefined input values
|
||||
if (value === null || value === undefined) {
|
||||
return { isValid: false, currentLength: null };
|
||||
}
|
||||
|
||||
// Check for TextEncoder availability
|
||||
if (typeof TextEncoder === 'undefined') {
|
||||
console.error('validateMaxByteLength: TextEncoder API is not available in this environment.');
|
||||
return { isValid: false, currentLength: null }; // Cannot determine byte length
|
||||
}
|
||||
|
||||
try {
|
||||
// Encode the string to UTF-8 bytes and get the length
|
||||
const encoder = new TextEncoder();
|
||||
const currentLength = encoder.encode(value).length;
|
||||
// Perform the byte length check
|
||||
const isValid = currentLength <= maxByteLength;
|
||||
|
||||
// Return the result object
|
||||
return { isValid, currentLength };
|
||||
} catch (error) {
|
||||
// Handle potential errors during encoding
|
||||
console.error('validateMaxByteLength: Error encoding string:', error);
|
||||
return { isValid: false, currentLength: null }; // Encoding failed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user