keyed conversations against from/to, updated tests
This commit is contained in:
@@ -13,7 +13,6 @@ export interface MessageInputProps {
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
|
||||
export const MessageInput = ({
|
||||
to,
|
||||
channel,
|
||||
|
||||
@@ -90,11 +90,7 @@ export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
||||
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
|
||||
if (message?.from === null || message?.from === undefined) return null;
|
||||
for (const device of getDevices()) {
|
||||
console.log("MessageItem: getDevices", { device });
|
||||
|
||||
if (device.nodes.has(message.from)) {
|
||||
console.log("MessageItem hasNode", { device, message });
|
||||
|
||||
return device.nodes.get(message.from) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,337 +1,372 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useMessageStore, Message, MessageState, MessageType } from './messageStore.ts';
|
||||
import { Types } from '@meshtastic/core';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useMessageStore,
|
||||
MessageType,
|
||||
MessageState,
|
||||
type Message,
|
||||
} from './messageStore.ts';
|
||||
|
||||
vi.mock('./storage/indexDB.ts', () => ({
|
||||
zustandIndexDBStorage: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
let memoryStorage: Record<string, string> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageStore.setState({
|
||||
messages: { direct: {}, broadcast: {} },
|
||||
draft: new Map<Types.Destination, string>(),
|
||||
nodeNum: 0,
|
||||
activeChat: 0,
|
||||
chatType: MessageType.Broadcast,
|
||||
});
|
||||
vi.mock('./storage/indexDB.ts', () => {
|
||||
console.log("Mocking zustandIndexDBStorage...");
|
||||
return {
|
||||
zustandIndexDBStorage: {
|
||||
getItem: vi.fn(async (name: string): Promise<string | null> => {
|
||||
console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null);
|
||||
return memoryStorage[name] ?? null;
|
||||
}),
|
||||
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
|
||||
console.log(`Mock setItem: ${name}`, value);
|
||||
memoryStorage[name] = value;
|
||||
}),
|
||||
removeItem: vi.fn(async (name: string): Promise<void> => {
|
||||
console.log(`Mock removeItem: ${name}`);
|
||||
delete memoryStorage[name];
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const myNodeNum = 111;
|
||||
const otherNodeNum1 = 222;
|
||||
const otherNodeNum2 = 333;
|
||||
const broadcastChannel = 0;
|
||||
|
||||
|
||||
|
||||
const directMessageToOther1: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum1,
|
||||
channel: 0,
|
||||
date: Date.now(),
|
||||
messageId: 101,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello other 1 from me',
|
||||
};
|
||||
|
||||
const directMessageFromOther1: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: otherNodeNum1,
|
||||
to: myNodeNum,
|
||||
channel: 0,
|
||||
date: Date.now() + 1000,
|
||||
messageId: 102,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello me from other 1',
|
||||
};
|
||||
|
||||
const directMessageToOther2: Message = {
|
||||
type: MessageType.Direct,
|
||||
from: myNodeNum,
|
||||
to: otherNodeNum2,
|
||||
channel: 0,
|
||||
date: Date.now() + 2000,
|
||||
messageId: 103,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello other 2 from me',
|
||||
};
|
||||
|
||||
const broadcastMessage1: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
from: otherNodeNum1,
|
||||
to: 0xffffffff,
|
||||
channel: broadcastChannel,
|
||||
date: Date.now() + 3000,
|
||||
messageId: 201,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast message 1',
|
||||
};
|
||||
|
||||
const broadcastMessage2: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
from: myNodeNum,
|
||||
to: 0xffffffff,
|
||||
channel: broadcastChannel,
|
||||
date: Date.now() + 4000,
|
||||
messageId: 202,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast message 2',
|
||||
};
|
||||
|
||||
describe('useMessageStore', () => {
|
||||
it('sets and gets nodeNum', () => {
|
||||
useMessageStore.getState().setNodeNum(42);
|
||||
expect(useMessageStore.getState().getNodeNum()).toBe(42);
|
||||
const initialState = useMessageStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageStore.setState(initialState, true);
|
||||
});
|
||||
|
||||
it('sets activeChat', () => {
|
||||
useMessageStore.getState().setActiveChat(123);
|
||||
expect(useMessageStore.getState().activeChat).toBe(123);
|
||||
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('sets chatType', () => {
|
||||
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('saves a direct message', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: Date.now(),
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello Direct',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.direct[101]?.[1]).toEqual(message);
|
||||
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('saves a broadcast message', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
channel: 5,
|
||||
to: 0,
|
||||
from: 303,
|
||||
date: Date.now(),
|
||||
messageId: 100,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast Message',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.broadcast[5]?.[100]).toEqual(message);
|
||||
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('ensures date is stored as milliseconds', () => {
|
||||
const now = Date.now();
|
||||
const message: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: now,
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello Direct',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.direct[101]?.[1]?.date).toBe(new Date(now).getTime());
|
||||
it('should save multiple messages correctly', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
|
||||
const state = useMessageStore.getState();
|
||||
|
||||
// Direct msg 1 (me -> other1)
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1);
|
||||
// Direct msg 2 (other1 -> me)
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1);
|
||||
// Broadcast msg 1
|
||||
expect(state.messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]).toEqual(broadcastMessage1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessages', () => {
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther2);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage2);
|
||||
});
|
||||
|
||||
it('should return broadcast messages for a channel, sorted by date', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, {
|
||||
myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice
|
||||
channel: broadcastChannel
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(broadcastMessage1);
|
||||
expect(messages[1]).toEqual(broadcastMessage2);
|
||||
});
|
||||
|
||||
it('should return empty array for broadcast if channel has no messages', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, {
|
||||
myNodeNum: myNodeNum,
|
||||
channel: 99
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return combined direct messages for a specific chat (pair), sorted by date', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: otherNodeNum1
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(directMessageToOther1);
|
||||
expect(messages[1]).toEqual(directMessageFromOther1);
|
||||
});
|
||||
|
||||
it('should return only relevant direct messages for a different chat pair', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: otherNodeNum2
|
||||
});
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(directMessageToOther2);
|
||||
});
|
||||
|
||||
it('should return empty array for direct chat if no messages exist', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: 999
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array if myNodeNum is not provided for direct messages', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
otherNodeNum: otherNodeNum1
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMessageState', () => {
|
||||
it('updates the state of an existing direct message', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: Date.now(),
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Change me',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: 101,
|
||||
messageId: 1,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
|
||||
expect(useMessageStore.getState().messages.direct[101]?.[1]?.state).toBe(MessageState.Ack);
|
||||
beforeEach(() => {
|
||||
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
});
|
||||
|
||||
it('updates the state of an existing broadcast message', () => {
|
||||
const message: Message = {
|
||||
type: MessageType.Broadcast,
|
||||
channel: 5,
|
||||
to: 0,
|
||||
from: 303,
|
||||
date: Date.now(),
|
||||
messageId: 100,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast Message',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
|
||||
it('should update state for a direct message sent BY ME', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Broadcast,
|
||||
key: 5,
|
||||
messageId: 100,
|
||||
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,
|
||||
});
|
||||
|
||||
expect(useMessageStore.getState().messages.broadcast[5]?.[100]?.state).toBe(MessageState.Failed);
|
||||
const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId];
|
||||
expect(message?.state).toBe(MessageState.Failed);
|
||||
});
|
||||
|
||||
it('does not update if the message is not found and logs a warning', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
|
||||
it('should update state for a broadcast message', () => {
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
key: 999,
|
||||
messageId: 99,
|
||||
type: MessageType.Broadcast,
|
||||
key: broadcastChannel,
|
||||
messageId: broadcastMessage1.messageId,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Message not found - type: direct, key: 999, messageId: 99',
|
||||
);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears all messages', () => {
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: MessageType.Broadcast,
|
||||
channel: 5,
|
||||
to: 0,
|
||||
from: 303,
|
||||
date: Date.now(),
|
||||
messageId: 100,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast Message',
|
||||
});
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: 101,
|
||||
from: 202,
|
||||
date: Date.now(),
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Hello Direct',
|
||||
});
|
||||
useMessageStore.getState().clearAllMessages();
|
||||
expect(useMessageStore.getState().messages.direct).toEqual({});
|
||||
expect(useMessageStore.getState().messages.broadcast).toEqual({});
|
||||
});
|
||||
|
||||
describe('getMessages', () => {
|
||||
it('retrieves sorted broadcast messages for a channel', () => {
|
||||
const now = Date.now();
|
||||
const earlier = now - 10000;
|
||||
const later = now;
|
||||
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: MessageType.Broadcast,
|
||||
channel: 4,
|
||||
to: 0,
|
||||
from: 404,
|
||||
date: later,
|
||||
messageId: 2,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Second',
|
||||
});
|
||||
useMessageStore.getState().saveMessage({
|
||||
type: MessageType.Broadcast,
|
||||
channel: 4,
|
||||
to: 0,
|
||||
from: 404,
|
||||
date: earlier,
|
||||
messageId: 1,
|
||||
state: MessageState.Waiting,
|
||||
message: 'First',
|
||||
});
|
||||
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 4 });
|
||||
expect(messages.map((m) => m.message)).toEqual(['First', 'Second']);
|
||||
expect(messages[0]?.date).toBe(earlier);
|
||||
expect(messages[1]?.date).toBe(later);
|
||||
const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId];
|
||||
expect(message?.state).toBe(MessageState.Ack);
|
||||
});
|
||||
|
||||
it('returns an empty array for broadcast messages if channel does not exist', () => {
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 99 });
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('merges and sorts direct messages by date', () => {
|
||||
const myNodeNum = 1;
|
||||
const otherNodeNum = 2;
|
||||
const now = Date.now();
|
||||
const earlier = now - 10000;
|
||||
const later = now + 10000;
|
||||
|
||||
const incomingMessage: Message = {
|
||||
it('should warn if message is not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
useMessageStore.getState().setMessageState({
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: myNodeNum,
|
||||
from: otherNodeNum,
|
||||
date: earlier,
|
||||
messageId: 1,
|
||||
state: MessageState.Ack,
|
||||
message: 'Incoming from 2',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(incomingMessage);
|
||||
|
||||
const outgoingMessage: Message = {
|
||||
type: MessageType.Direct,
|
||||
channel: 0,
|
||||
to: otherNodeNum,
|
||||
from: myNodeNum,
|
||||
date: later,
|
||||
messageId: 2,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Outgoing from 1',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(outgoingMessage);
|
||||
|
||||
const merged = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: otherNodeNum,
|
||||
key: otherNodeNum1,
|
||||
messageId: 999,
|
||||
newState: MessageState.Ack,
|
||||
});
|
||||
|
||||
expect(merged.length).toBe(2);
|
||||
expect(merged.map((m) => m.message)).toEqual(['Incoming from 2', 'Outgoing from 1']);
|
||||
expect(merged[0]?.date).toBe(earlier);
|
||||
expect(merged[1]?.date).toBe(later);
|
||||
});
|
||||
|
||||
it('returns an empty array for direct messages if no messages exist between nodes', () => {
|
||||
const myNodeNum = 1;
|
||||
const otherNodeNum = 2;
|
||||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||
myNodeNum: myNodeNum,
|
||||
otherNodeNum: otherNodeNum,
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message not found for state update'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('draft functionality', () => {
|
||||
it('sets and gets a draft message', () => {
|
||||
const key: Types.Destination = 123;
|
||||
useMessageStore.getState().setDraft(key, 'Draft text');
|
||||
expect(useMessageStore.getState().getDraft(key)).toBe('Draft text');
|
||||
});
|
||||
|
||||
it('gets an empty string if no draft exists for a key', () => {
|
||||
const key: Types.Destination = 456;
|
||||
expect(useMessageStore.getState().getDraft(key)).toBe('');
|
||||
});
|
||||
|
||||
it('clears a draft message', () => {
|
||||
const key: Types.Destination = 123;
|
||||
useMessageStore.getState().setDraft(key, 'Draft to clear');
|
||||
useMessageStore.getState().clearDraft(key);
|
||||
expect(useMessageStore.getState().getDraft(key)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearMessageByMessageId', () => {
|
||||
it('clears a direct message by messageId', () => {
|
||||
const message: Message = {
|
||||
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,
|
||||
channel: 0,
|
||||
to: 111,
|
||||
from: 222,
|
||||
date: Date.now(),
|
||||
messageId: 42,
|
||||
state: MessageState.Waiting,
|
||||
message: 'To be deleted',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.direct[111]?.[42]).toBeDefined();
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId(MessageType.Direct, 42);
|
||||
|
||||
expect(useMessageStore.getState().messages.direct[111]?.[42]).toBeUndefined();
|
||||
expect(useMessageStore.getState().messages.direct[111]).toBeUndefined();
|
||||
sender: myNodeNum,
|
||||
recipient: otherNodeNum1,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[messageIdToDelete]).toBeUndefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toBeDefined();
|
||||
});
|
||||
|
||||
it('clears a broadcast message by messageId', () => {
|
||||
const message: Message = {
|
||||
it('should delete a specific direct message (sent by other)', () => {
|
||||
const messageIdToDelete = directMessageFromOther1.messageId;
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Direct,
|
||||
sender: otherNodeNum1,
|
||||
recipient: myNodeNum,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[messageIdToDelete]).toBeUndefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toBeDefined();
|
||||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
|
||||
});
|
||||
|
||||
it('should delete a specific broadcast message', () => {
|
||||
const messageIdToDelete = broadcastMessage1.messageId;
|
||||
useMessageStore.getState().clearMessageByMessageId({
|
||||
type: MessageType.Broadcast,
|
||||
channel: 2,
|
||||
to: 0,
|
||||
from: 333,
|
||||
date: Date.now(),
|
||||
messageId: 77,
|
||||
state: MessageState.Waiting,
|
||||
message: 'Broadcast to delete',
|
||||
};
|
||||
useMessageStore.getState().saveMessage(message);
|
||||
expect(useMessageStore.getState().messages.broadcast[2]?.[77]).toBeDefined();
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId(MessageType.Broadcast, 77);
|
||||
|
||||
expect(useMessageStore.getState().messages.broadcast[2]?.[77]).toBeUndefined();
|
||||
expect(useMessageStore.getState().messages.broadcast[2]).toBeUndefined();
|
||||
channel: broadcastChannel,
|
||||
messageId: messageIdToDelete
|
||||
});
|
||||
const state = useMessageStore.getState();
|
||||
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not throw error if trying to clear a non-existent message', () => {
|
||||
expect(() => {
|
||||
useMessageStore.getState().clearMessageByMessageId(MessageType.Direct, 999);
|
||||
useMessageStore.getState().clearMessageByMessageId(MessageType.Broadcast, 999);
|
||||
}).not.toThrow();
|
||||
it('should clean up empty recipient/sender/channel objects', () => {
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, sender: otherNodeNum1, recipient: myNodeNum, messageId: directMessageFromOther1.messageId });
|
||||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed
|
||||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed
|
||||
|
||||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channel: broadcastChannel, messageId: broadcastMessage1.messageId });
|
||||
expect(useMessageStore.getState().messages.broadcast[broadcastChannel]).toBeUndefined(); // Channel level removed
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drafts', () => {
|
||||
const draftKey = otherNodeNum1;
|
||||
const draftMessage = 'This is a draft';
|
||||
|
||||
it('should set and get a draft', () => {
|
||||
useMessageStore.getState().setDraft(draftKey, draftMessage);
|
||||
expect(useMessageStore.getState().draft.get(draftKey)).toBe(draftMessage);
|
||||
expect(useMessageStore.getState().getDraft(draftKey)).toBe(draftMessage);
|
||||
});
|
||||
|
||||
it('should return empty string for non-existent draft', () => {
|
||||
expect(useMessageStore.getState().getDraft(999)).toBe('');
|
||||
});
|
||||
|
||||
it('should clear a draft', () => {
|
||||
useMessageStore.getState().setDraft(draftKey, draftMessage);
|
||||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(true);
|
||||
useMessageStore.getState().clearDraft(draftKey);
|
||||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(false);
|
||||
expect(useMessageStore.getState().getDraft(draftKey)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllMessages', () => {
|
||||
it('should clear all direct and broadcast messages', () => {
|
||||
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||
expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0);
|
||||
|
||||
useMessageStore.getState().clearAllMessages();
|
||||
|
||||
expect(useMessageStore.getState().messages.direct).toEqual({});
|
||||
expect(useMessageStore.getState().messages.broadcast).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ interface MessageBase {
|
||||
channel: Types.ChannelNumber;
|
||||
to: number;
|
||||
from: number;
|
||||
date: number; // Unix timestamp in milliseconds
|
||||
date: number;
|
||||
messageId: number;
|
||||
state: MessageState;
|
||||
message: string;
|
||||
@@ -33,12 +33,12 @@ export type Message = GenericMessage<MessageType.Direct> | GenericMessage<Messag
|
||||
|
||||
export interface MessageStore {
|
||||
messages: {
|
||||
direct: Record<number, Record<number, Message>>; // other_node_num -> messageId -> Message
|
||||
direct: Record<number, Record<number, Record<number, Message>>>;
|
||||
broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
|
||||
};
|
||||
draft: Map<Types.Destination, string>;
|
||||
nodeNum: number;
|
||||
activeChat: number;
|
||||
nodeNum: number; // This device's node number
|
||||
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
|
||||
chatType: MessageType;
|
||||
|
||||
setNodeNum: (nodeNum: number) => void;
|
||||
@@ -48,24 +48,33 @@ export interface MessageStore {
|
||||
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[];
|
||||
getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[];
|
||||
getDraft: (key: Types.Destination) => string;
|
||||
setDraft: (key: Types.Destination, message: string) => void;
|
||||
clearAllMessages: () => void;
|
||||
clearMessageByMessageId: (type: MessageType, messageId: number) => void;
|
||||
clearMessageByMessageId: (params: {
|
||||
type: MessageType;
|
||||
sender?: number;
|
||||
recipient?: number;
|
||||
channel?: number;
|
||||
messageId: number
|
||||
}) => void;
|
||||
clearDraft: (key: Types.Destination) => void;
|
||||
|
||||
}
|
||||
|
||||
const CURRENT_STORE_VERSION = 0;
|
||||
|
||||
export const useMessageStore = create<MessageStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messages: {
|
||||
direct: {},
|
||||
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
|
||||
broadcast: {},
|
||||
},
|
||||
draft: new Map<number, string>(),
|
||||
@@ -90,15 +99,25 @@ export const useMessageStore = create<MessageStore>()(
|
||||
},
|
||||
saveMessage: (message) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
const group = state.messages[message.type];
|
||||
// Direct messages are keyed by the RECIPIENT's node number (`message.to`)
|
||||
// Broadcast messages are keyed by the channel number (`message.channel`)
|
||||
const key = message.type === MessageType.Direct ? Number(message.to) : Number(message.channel);
|
||||
if (!group[key]) {
|
||||
group[key] = {};
|
||||
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;
|
||||
}
|
||||
const messageToSave = { ...message };
|
||||
group[key][message.messageId] = messageToSave;
|
||||
}));
|
||||
},
|
||||
setMessageState: ({
|
||||
@@ -109,21 +128,30 @@ export const useMessageStore = create<MessageStore>()(
|
||||
}) => {
|
||||
set(
|
||||
produce((state: MessageStore) => {
|
||||
const message = state.messages[type]?.[key]?.[messageId];
|
||||
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 - type: ${type}, key: ${key}, messageId: ${messageId}`);
|
||||
console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
clearMessages: () => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.messages.direct = {};
|
||||
state.messages.broadcast = {};
|
||||
}));
|
||||
},
|
||||
getMessages: (type, options) => {
|
||||
const state = get();
|
||||
|
||||
@@ -133,25 +161,47 @@ export const useMessageStore = create<MessageStore>()(
|
||||
}
|
||||
|
||||
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
|
||||
// Messages TO the other node (sent by me) are keyed under their nodeNum
|
||||
const messagesToOtherNodeMap = state.messages.direct[options.otherNodeNum] ?? {};
|
||||
const sentByMe = Object.values(messagesToOtherNodeMap);
|
||||
const myNodeNum = options.myNodeNum;
|
||||
const otherNodeNum = options.otherNodeNum;
|
||||
|
||||
// Messages TO me (potentially from the other node) are keyed under my nodeNum
|
||||
const messagesToMeMap = state.messages.direct[options.myNodeNum] ?? {};
|
||||
// Filter messages TO me to find the ones FROM the specific other node
|
||||
const sentByOtherNode = Object.values(messagesToMeMap).filter(
|
||||
(msg) => msg.from === 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, ...sentByOtherNode].sort(
|
||||
(a, b) => a.date - b.date
|
||||
);
|
||||
return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date);
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
clearMessageByMessageId: ({ type, sender, recipient, channel, messageId }) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
if (type === MessageType.Broadcast && channel !== undefined) {
|
||||
const messageMap = state.messages.broadcast[channel];
|
||||
if (messageMap?.[messageId]) {
|
||||
delete messageMap[messageId];
|
||||
if (Object.keys(messageMap).length === 0) {
|
||||
delete state.messages.broadcast[channel];
|
||||
}
|
||||
}
|
||||
} else if (type === MessageType.Direct && sender !== undefined && recipient !== undefined) {
|
||||
const messageMap = state.messages.direct?.[sender]?.[recipient];
|
||||
if (messageMap?.[messageId]) {
|
||||
delete messageMap[messageId];
|
||||
if (Object.keys(messageMap).length === 0) {
|
||||
delete state.messages.direct[sender][recipient];
|
||||
if (Object.keys(state.messages.direct[sender]).length === 0) {
|
||||
delete state.messages.direct[sender];
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn("clearMessageByMessageId called without sufficient identifiers for type", type);
|
||||
}
|
||||
}));
|
||||
},
|
||||
getDraft: (key) => {
|
||||
return get().draft.get(key) ?? '';
|
||||
},
|
||||
@@ -160,20 +210,6 @@ export const useMessageStore = create<MessageStore>()(
|
||||
state.draft.set(key, message);
|
||||
}));
|
||||
},
|
||||
clearMessageByMessageId: (type, messageId) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
const group = state.messages[type];
|
||||
for (const key in group) {
|
||||
if (group[key][messageId]) {
|
||||
delete group[key][messageId];
|
||||
if (Object.keys(group[key]).length === 0) {
|
||||
delete group[key];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
clearDraft: (key) => {
|
||||
set(produce((state: MessageStore) => {
|
||||
state.draft.delete(key);
|
||||
@@ -189,9 +225,10 @@ export const useMessageStore = create<MessageStore>()(
|
||||
{
|
||||
name: 'meshtastic-message-store',
|
||||
storage: createJSONStorage(() => zustandIndexDBStorage),
|
||||
version: CURRENT_STORE_VERSION,
|
||||
partialize: (state) => ({
|
||||
messages: state.messages,
|
||||
nodeNum: state.nodeNum,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
));
|
||||
@@ -153,6 +153,7 @@ export const MessagesPage = () => {
|
||||
|
||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||
<MessageInput
|
||||
from={activeChat}
|
||||
to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast}
|
||||
channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id}
|
||||
maxBytes={200}
|
||||
|
||||
Reference in New Issue
Block a user