Browse Source

keyed conversations against from/to, updated tests

pull/536/head
Dan Ditomaso 1 year ago
parent
commit
eadadb5d1d
  1. 1
      src/components/PageComponents/Messages/MessageInput.tsx
  2. 4
      src/components/PageComponents/Messages/MessageItem.tsx
  3. 579
      src/core/stores/messageStore.test.ts
  4. 143
      src/core/stores/messageStore.ts
  5. 1
      src/pages/Messages.tsx

1
src/components/PageComponents/Messages/MessageInput.tsx

@ -13,7 +13,6 @@ export interface MessageInputProps {
maxBytes: number; maxBytes: number;
} }
export const MessageInput = ({ export const MessageInput = ({
to, to,
channel, channel,

4
src/components/PageComponents/Messages/MessageItem.tsx

@ -90,11 +90,7 @@ export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => { const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
if (message?.from === null || message?.from === undefined) return null; if (message?.from === null || message?.from === undefined) return null;
for (const device of getDevices()) { for (const device of getDevices()) {
console.log("MessageItem: getDevices", { device });
if (device.nodes.has(message.from)) { if (device.nodes.has(message.from)) {
console.log("MessageItem hasNode", { device, message });
return device.nodes.get(message.from) ?? null; return device.nodes.get(message.from) ?? null;
} }
} }

579
src/core/stores/messageStore.test.ts

@ -1,337 +1,372 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useMessageStore, Message, MessageState, MessageType } from './messageStore.ts'; import {
import { Types } from '@meshtastic/core'; useMessageStore,
MessageType,
vi.mock('./storage/indexDB.ts', () => ({ MessageState,
zustandIndexDBStorage: { type Message,
getItem: vi.fn(), } from './messageStore.ts';
setItem: vi.fn(),
removeItem: vi.fn(), let memoryStorage: Record<string, string> = {};
},
})); vi.mock('./storage/indexDB.ts', () => {
console.log("Mocking zustandIndexDBStorage...");
beforeEach(() => { return {
useMessageStore.setState({ zustandIndexDBStorage: {
messages: { direct: {}, broadcast: {} }, getItem: vi.fn(async (name: string): Promise<string | null> => {
draft: new Map<Types.Destination, string>(), console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null);
nodeNum: 0, return memoryStorage[name] ?? null;
activeChat: 0, }),
chatType: MessageType.Broadcast, 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', () => { describe('useMessageStore', () => {
it('sets and gets nodeNum', () => { const initialState = useMessageStore.getState();
useMessageStore.getState().setNodeNum(42);
expect(useMessageStore.getState().getNodeNum()).toBe(42); beforeEach(() => {
useMessageStore.setState(initialState, true);
}); });
it('sets activeChat', () => { it('should have correct initial state', () => {
useMessageStore.getState().setActiveChat(123); const state = useMessageStore.getState();
expect(useMessageStore.getState().activeChat).toBe(123); 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); useMessageStore.getState().setChatType(MessageType.Direct);
expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1);
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct); expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
}); });
describe('saveMessage', () => { describe('saveMessage', () => {
it('saves a direct message', () => { it('should save a direct message with correct structure', () => {
const message: Message = { useMessageStore.getState().saveMessage(directMessageToOther1);
type: MessageType.Direct, const state = useMessageStore.getState();
channel: 0, expect(state.messages.direct[myNodeNum]).toBeDefined();
to: 101, expect(state.messages.direct[myNodeNum][otherNodeNum1]).toBeDefined();
from: 202, expect(
date: Date.now(), state.messages.direct[myNodeNum][otherNodeNum1][directMessageToOther1.messageId],
messageId: 1, ).toEqual(directMessageToOther1);
state: MessageState.Waiting,
message: 'Hello Direct',
};
useMessageStore.getState().saveMessage(message);
expect(useMessageStore.getState().messages.direct[101]?.[1]).toEqual(message);
}); });
it('saves a broadcast message', () => { it('should save a broadcast message with correct structure', () => {
const message: Message = { useMessageStore.getState().saveMessage(broadcastMessage1);
type: MessageType.Broadcast, const state = useMessageStore.getState();
channel: 5, expect(state.messages.broadcast[broadcastChannel]).toBeDefined();
to: 0, expect(
from: 303, state.messages.broadcast[broadcastChannel][broadcastMessage1.messageId],
date: Date.now(), ).toEqual(broadcastMessage1);
messageId: 100,
state: MessageState.Waiting,
message: 'Broadcast Message',
};
useMessageStore.getState().saveMessage(message);
expect(useMessageStore.getState().messages.broadcast[5]?.[100]).toEqual(message);
}); });
it('ensures date is stored as milliseconds', () => { it('should save multiple messages correctly', () => {
const now = Date.now(); useMessageStore.getState().saveMessage(directMessageToOther1);
const message: Message = { useMessageStore.getState().saveMessage(directMessageFromOther1);
type: MessageType.Direct, useMessageStore.getState().saveMessage(broadcastMessage1);
channel: 0,
to: 101, const state = useMessageStore.getState();
from: 202,
date: now, // Direct msg 1 (me -> other1)
messageId: 1, expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1);
state: MessageState.Waiting, // Direct msg 2 (other1 -> me)
message: 'Hello Direct', expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1);
}; // Broadcast msg 1
useMessageStore.getState().saveMessage(message); expect(state.messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]).toEqual(broadcastMessage1);
expect(useMessageStore.getState().messages.direct[101]?.[1]?.date).toBe(new Date(now).getTime());
}); });
}); });
describe('setMessageState', () => { describe('getMessages', () => {
it('updates the state of an existing direct message', () => { beforeEach(() => {
const message: Message = { useMessageStore.getState().setNodeNum(myNodeNum);
type: MessageType.Direct, useMessageStore.getState().saveMessage(directMessageToOther1);
channel: 0, useMessageStore.getState().saveMessage(directMessageFromOther1);
to: 101, useMessageStore.getState().saveMessage(directMessageToOther2);
from: 202, useMessageStore.getState().saveMessage(broadcastMessage1);
date: Date.now(), useMessageStore.getState().saveMessage(broadcastMessage2);
messageId: 1, });
state: MessageState.Waiting,
message: 'Change me',
};
useMessageStore.getState().saveMessage(message);
useMessageStore.getState().setMessageState({ it('should return broadcast messages for a channel, sorted by date', () => {
type: MessageType.Direct, const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, {
key: 101, myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice
messageId: 1, channel: broadcastChannel
newState: MessageState.Ack,
}); });
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(broadcastMessage1);
expect(messages[1]).toEqual(broadcastMessage2);
});
expect(useMessageStore.getState().messages.direct[101]?.[1]?.state).toBe(MessageState.Ack); 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('updates the state of an existing broadcast message', () => { it('should return combined direct messages for a specific chat (pair), sorted by date', () => {
const message: Message = { const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
type: MessageType.Broadcast, myNodeNum: myNodeNum,
channel: 5, otherNodeNum: otherNodeNum1
to: 0, });
from: 303, expect(messages).toHaveLength(2);
date: Date.now(), expect(messages[0]).toEqual(directMessageToOther1);
messageId: 100, expect(messages[1]).toEqual(directMessageFromOther1);
state: MessageState.Waiting, });
message: 'Broadcast Message',
};
useMessageStore.getState().saveMessage(message);
useMessageStore.getState().setMessageState({ it('should return only relevant direct messages for a different chat pair', () => {
type: MessageType.Broadcast, const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
key: 5, myNodeNum: myNodeNum,
messageId: 100, otherNodeNum: otherNodeNum2
newState: MessageState.Failed, });
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([]);
});
expect(useMessageStore.getState().messages.broadcast[5]?.[100]?.state).toBe(MessageState.Failed); 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([]);
}); });
});
it('does not update if the message is not found and logs a warning', () => { describe('setMessageState', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); 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({ useMessageStore.getState().setMessageState({
type: MessageType.Direct, type: MessageType.Direct,
key: 999, key: otherNodeNum1,
messageId: 99, messageId: directMessageToOther1.messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
const message = useMessageStore.getState().messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId];
expect(consoleWarnSpy).toHaveBeenCalledWith( expect(message?.state).toBe(MessageState.Ack);
'Message not found - type: direct, key: 999, messageId: 99',
);
consoleWarnSpy.mockRestore();
}); });
});
it('clears all messages', () => { it('should update state for a direct message received FROM OTHER', () => {
useMessageStore.getState().saveMessage({ useMessageStore.getState().setMessageState({
type: MessageType.Broadcast, type: MessageType.Direct,
channel: 5, key: otherNodeNum1,
to: 0, messageId: directMessageFromOther1.messageId,
from: 303, newState: MessageState.Failed,
date: Date.now(), });
messageId: 100, const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId];
state: MessageState.Waiting, expect(message?.state).toBe(MessageState.Failed);
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('should update state for a broadcast message', () => {
it('retrieves sorted broadcast messages for a channel', () => { useMessageStore.getState().setMessageState({
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, type: MessageType.Broadcast,
channel: 4, key: broadcastChannel,
to: 0, messageId: broadcastMessage1.messageId,
from: 404, newState: MessageState.Ack,
date: earlier,
messageId: 1,
state: MessageState.Waiting,
message: 'First',
}); });
const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId];
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 4 }); expect(message?.state).toBe(MessageState.Ack);
expect(messages.map((m) => m.message)).toEqual(['First', 'Second']);
expect(messages[0]?.date).toBe(earlier);
expect(messages[1]?.date).toBe(later);
}); });
it('returns an empty array for broadcast messages if channel does not exist', () => { it('should warn if message is not found', () => {
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { channel: 99 }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
expect(messages).toEqual([]); 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();
}); });
});
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 = { describe('clearMessageByMessageId', () => {
type: MessageType.Direct, beforeEach(() => {
channel: 0, useMessageStore.getState().setNodeNum(myNodeNum);
to: myNodeNum, useMessageStore.getState().saveMessage(directMessageToOther1);
from: otherNodeNum, useMessageStore.getState().saveMessage(directMessageFromOther1);
date: earlier, useMessageStore.getState().saveMessage(broadcastMessage1);
messageId: 1, useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: 1011, date: Date.now() + 50 });
state: MessageState.Ack, });
message: 'Incoming from 2',
}; it('should delete a specific direct message (sent by me)', () => {
useMessageStore.getState().saveMessage(incomingMessage); const messageIdToDelete = directMessageToOther1.messageId;
useMessageStore.getState().clearMessageByMessageId({
const outgoingMessage: Message = {
type: MessageType.Direct, type: MessageType.Direct,
channel: 0, sender: myNodeNum,
to: otherNodeNum, recipient: otherNodeNum1,
from: myNodeNum, messageId: messageIdToDelete
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,
}); });
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();
});
expect(merged.length).toBe(2); it('should delete a specific direct message (sent by other)', () => {
expect(merged.map((m) => m.message)).toEqual(['Incoming from 2', 'Outgoing from 1']); const messageIdToDelete = directMessageFromOther1.messageId;
expect(merged[0]?.date).toBe(earlier); useMessageStore.getState().clearMessageByMessageId({
expect(merged[1]?.date).toBe(later); 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('returns an empty array for direct messages if no messages exist between nodes', () => { it('should delete a specific broadcast message', () => {
const myNodeNum = 1; const messageIdToDelete = broadcastMessage1.messageId;
const otherNodeNum = 2; useMessageStore.getState().clearMessageByMessageId({
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { type: MessageType.Broadcast,
myNodeNum: myNodeNum, channel: broadcastChannel,
otherNodeNum: otherNodeNum, messageId: messageIdToDelete
}); });
expect(messages).toEqual([]); const state = useMessageStore.getState();
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined();
});
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('draft functionality', () => { describe('Drafts', () => {
it('sets and gets a draft message', () => { const draftKey = otherNodeNum1;
const key: Types.Destination = 123; const draftMessage = 'This is a draft';
useMessageStore.getState().setDraft(key, 'Draft text');
expect(useMessageStore.getState().getDraft(key)).toBe('Draft text'); 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('gets an empty string if no draft exists for a key', () => { it('should return empty string for non-existent draft', () => {
const key: Types.Destination = 456; expect(useMessageStore.getState().getDraft(999)).toBe('');
expect(useMessageStore.getState().getDraft(key)).toBe('');
}); });
it('clears a draft message', () => { it('should clear a draft', () => {
const key: Types.Destination = 123; useMessageStore.getState().setDraft(draftKey, draftMessage);
useMessageStore.getState().setDraft(key, 'Draft to clear'); expect(useMessageStore.getState().draft.has(draftKey)).toBe(true);
useMessageStore.getState().clearDraft(key); useMessageStore.getState().clearDraft(draftKey);
expect(useMessageStore.getState().getDraft(key)).toBe(''); expect(useMessageStore.getState().draft.has(draftKey)).toBe(false);
expect(useMessageStore.getState().getDraft(draftKey)).toBe('');
}); });
}); });
describe('clearMessageByMessageId', () => { describe('clearAllMessages', () => {
it('clears a direct message by messageId', () => { it('should clear all direct and broadcast messages', () => {
const message: Message = { useMessageStore.getState().saveMessage(directMessageToOther1);
type: MessageType.Direct, useMessageStore.getState().saveMessage(broadcastMessage1);
channel: 0, expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0);
to: 111, expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0);
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();
});
it('clears a broadcast message by messageId', () => { useMessageStore.getState().clearAllMessages();
const message: Message = {
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();
});
it('does not throw error if trying to clear a non-existent message', () => { expect(useMessageStore.getState().messages.direct).toEqual({});
expect(() => { expect(useMessageStore.getState().messages.broadcast).toEqual({});
useMessageStore.getState().clearMessageByMessageId(MessageType.Direct, 999);
useMessageStore.getState().clearMessageByMessageId(MessageType.Broadcast, 999);
}).not.toThrow();
}); });
}); });
});
});

143
src/core/stores/messageStore.ts

@ -19,7 +19,7 @@ interface MessageBase {
channel: Types.ChannelNumber; channel: Types.ChannelNumber;
to: number; to: number;
from: number; from: number;
date: number; // Unix timestamp in milliseconds date: number;
messageId: number; messageId: number;
state: MessageState; state: MessageState;
message: string; message: string;
@ -33,12 +33,12 @@ export type Message = GenericMessage<MessageType.Direct> | GenericMessage<Messag
export interface MessageStore { export interface MessageStore {
messages: { 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 broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
}; };
draft: Map<Types.Destination, string>; draft: Map<Types.Destination, string>;
nodeNum: number; nodeNum: number; // This device's node number
activeChat: number; activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
chatType: MessageType; chatType: MessageType;
setNodeNum: (nodeNum: number) => void; setNodeNum: (nodeNum: number) => void;
@ -48,24 +48,33 @@ export interface MessageStore {
saveMessage: (message: Message) => void; saveMessage: (message: Message) => void;
setMessageState: (params: { setMessageState: (params: {
type: MessageType; type: MessageType;
// For Direct: Represents the *other* node number involved in the chat.
// For Broadcast: Represents the channel number.
key: number; key: number;
messageId: number; messageId: number;
newState?: MessageState; newState?: MessageState;
}) => void; }) => 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; getDraft: (key: Types.Destination) => string;
setDraft: (key: Types.Destination, message: string) => void; setDraft: (key: Types.Destination, message: string) => void;
clearAllMessages: () => 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; clearDraft: (key: Types.Destination) => void;
} }
const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()( export const useMessageStore = create<MessageStore>()(
persist( persist(
(set, get) => ({ (set, get) => ({
messages: { messages: {
direct: {}, direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
broadcast: {}, broadcast: {},
}, },
draft: new Map<number, string>(), draft: new Map<number, string>(),
@ -90,15 +99,25 @@ export const useMessageStore = create<MessageStore>()(
}, },
saveMessage: (message) => { saveMessage: (message) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
const group = state.messages[message.type]; if (message.type === MessageType.Direct) {
// Direct messages are keyed by the RECIPIENT's node number (`message.to`) const sender = Number(message.from);
// Broadcast messages are keyed by the channel number (`message.channel`) const recipient = Number(message.to);
const key = message.type === MessageType.Direct ? Number(message.to) : Number(message.channel);
if (!group[key]) { if (!state.messages.direct[sender]) {
group[key] = {}; 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: ({ setMessageState: ({
@ -109,21 +128,30 @@ export const useMessageStore = create<MessageStore>()(
}) => { }) => {
set( set(
produce((state: MessageStore) => { 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) { if (message) {
message.state = newState; message.state = newState;
} else { } 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) => { getMessages: (type, options) => {
const state = get(); const state = get();
@ -133,25 +161,47 @@ export const useMessageStore = create<MessageStore>()(
} }
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) { if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
// Messages TO the other node (sent by me) are keyed under their nodeNum const myNodeNum = options.myNodeNum;
const messagesToOtherNodeMap = state.messages.direct[options.otherNodeNum] ?? {}; const otherNodeNum = options.otherNodeNum;
const sentByMe = Object.values(messagesToOtherNodeMap);
// Messages sent BY ME TO OTHER
const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {};
const sentByMe = Object.values(sentByMeMap);
// Messages TO me (potentially from the other node) are keyed under my nodeNum // Messages sent BY OTHER TO ME
const messagesToMeMap = state.messages.direct[options.myNodeNum] ?? {}; const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {};
// Filter messages TO me to find the ones FROM the specific other node const sentByOther = Object.values(sentByOtherMap);
const sentByOtherNode = Object.values(messagesToMeMap).filter(
(msg) => msg.from === options.otherNodeNum
);
// Merge and sort chronologically // Merge and sort chronologically
return [...sentByMe, ...sentByOtherNode].sort( return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date);
(a, b) => a.date - b.date
);
} }
return []; 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) => { getDraft: (key) => {
return get().draft.get(key) ?? ''; return get().draft.get(key) ?? '';
}, },
@ -160,20 +210,6 @@ export const useMessageStore = create<MessageStore>()(
state.draft.set(key, message); 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) => { clearDraft: (key) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
state.draft.delete(key); state.draft.delete(key);
@ -189,9 +225,10 @@ export const useMessageStore = create<MessageStore>()(
{ {
name: 'meshtastic-message-store', name: 'meshtastic-message-store',
storage: createJSONStorage(() => zustandIndexDBStorage), storage: createJSONStorage(() => zustandIndexDBStorage),
version: CURRENT_STORE_VERSION,
partialize: (state) => ({ partialize: (state) => ({
messages: state.messages, messages: state.messages,
nodeNum: state.nodeNum,
}), }),
} }
) ));
);

1
src/pages/Messages.tsx

@ -153,6 +153,7 @@ export const MessagesPage = () => {
<div className="shrink-0 p-4 w-full dark:bg-slate-900"> <div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput <MessageInput
from={activeChat}
to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast} to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast}
channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id} channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id}
maxBytes={200} maxBytes={200}

Loading…
Cancel
Save