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:
Dan Ditomaso
2025-05-02 09:00:24 -04:00
committed by GitHub
parent 3eafad7261
commit 99711fc44e
78 changed files with 2764 additions and 2028 deletions
+40
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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) => {
-371
View File
@@ -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({});
});
});
});
-234
View File
@@ -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,
}),
}
));
+212
View File
@@ -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);
});
});
});
+70
View File
@@ -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,
}
+37
View File
@@ -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;
};
+68 -1
View File
@@ -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);
},
};
+13 -8
View File
@@ -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]);
+40
View File
@@ -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
}
}