diff --git a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts index b26d0165..9e8fa30e 100644 --- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts +++ b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts @@ -3,8 +3,8 @@ import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { useDevice } from "@core/stores/deviceStore.ts"; -vi.mock("@core/stores/appStore.ts", () => ({ - useAppStore: vi.fn(() => ({ activeChat: "chat-123" })), +vi.mock("@core/stores/messageStore.ts", () => ({ + useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })), })); vi.mock("@core/stores/deviceStore.ts", () => ({ diff --git a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts index 821aade7..4a291eea 100644 --- a/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts +++ b/src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts @@ -1,10 +1,10 @@ import { useCallback } from "react"; -import { useAppStore } from "@core/stores/appStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { useMessageStore } from "@core/stores/messageStore.ts"; export function useRefreshKeysDialog() { const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); - const { activeChat } = useAppStore(); + const { activeChat } = useMessageStore(); const handleNodeRemove = useCallback(() => { const nodeWithError = getNodeError(activeChat); diff --git a/src/components/UI/Sidebar/sidebarButton.tsx b/src/components/UI/Sidebar/sidebarButton.tsx index fa569968..5a5efe07 100644 --- a/src/components/UI/Sidebar/sidebarButton.tsx +++ b/src/components/UI/Sidebar/sidebarButton.tsx @@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react"; export interface SidebarButtonProps { label: string; + count?: number; active?: boolean; Icon?: LucideIcon; element?; @@ -14,6 +15,7 @@ export const SidebarButton = ({ label, active, Icon, + count, element, onClick, disabled = false, @@ -28,5 +30,6 @@ export const SidebarButton = ({ {Icon && } {element && element} {label} + {count > 0 && !active &&
{count}
} ); diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index 58736738..68f34e51 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -105,6 +105,8 @@ export interface Device { clearNodeError: (nodeNum: number) => void; getNodeError: (nodeNum: number) => NodeError | undefined; hasNodeError: (nodeNum: number) => boolean + incrementUnread: (nodeNum: number) => void; + resetUnread: (nodeNum: number) => void; } export interface DeviceState { @@ -152,6 +154,7 @@ export const useDeviceStore = createStore((set, get) => ({ unsafeRoles: false, refreshKeys: false, rebootOTA: false, + clearMessages: false, }, pendingSettingsChanges: false, messageDraft: "", @@ -613,7 +616,35 @@ export const useDeviceStore = createStore((set, get) => ({ } return device.nodeErrors.has(nodeNum); }, + incrementUnread: (nodeNum: number) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + console.warn(`incrementUnread: Device with ID ${id} not found.`); + return; + } + const currentCount = device.unreadCounts.get(nodeNum) ?? 0; + device.unreadCounts.set(nodeNum, currentCount + 1); + }) + ); + }, + resetUnread: (nodeNum: number) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + console.warn(`resetUnread: Device with ID ${id} not found.`); + return; + } + device.unreadCounts.set(nodeNum, 0); + if (device.unreadCounts.get(nodeNum) === 0) { + device.unreadCounts.delete(nodeNum); + } + }) + ); + }, }); }), ); diff --git a/src/core/stores/messageStore.test.ts b/src/core/stores/messageStore.test.ts index e8fc70c7..a8dc9880 100644 --- a/src/core/stores/messageStore.test.ts +++ b/src/core/stores/messageStore.test.ts @@ -9,19 +9,15 @@ import { let memoryStorage: Record = {}; vi.mock('./storage/indexDB.ts', () => { - console.log("Mocking zustandIndexDBStorage..."); return { zustandIndexDBStorage: { getItem: vi.fn(async (name: string): Promise => { - console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null); return memoryStorage[name] ?? null; }), setItem: vi.fn(async (name: string, value: string): Promise => { - console.log(`Mock setItem: ${name}`, value); memoryStorage[name] = value; }), removeItem: vi.fn(async (name: string): Promise => { - console.log(`Mock removeItem: ${name}`); delete memoryStorage[name]; }), }, @@ -211,11 +207,14 @@ describe('useMessageStore', () => { expect(messages).toEqual([]); }); - it('should return empty array if myNodeNum is not provided for direct messages', () => { + 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).toEqual([]); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual(directMessageToOther1); + expect(messages[1]).toEqual(directMessageFromOther1); }); }); diff --git a/src/core/subscriptions.ts b/src/core/subscriptions.ts index b1d19901..279e5058 100644 --- a/src/core/subscriptions.ts +++ b/src/core/subscriptions.ts @@ -89,6 +89,16 @@ export const subscribeAll = ( const dto = new PacketToMessageDTO(messagePacket, myNodeNum); const message = dto.toMessage(); messageStore.saveMessage(message); + + if (message.type == MessageType.Direct) { + if (message.to === myNodeNum) { + device.incrementUnread(messagePacket.from); + } + } else if (message.type == MessageType.Broadcast) { + if (message.from !== myNodeNum) { + device.incrementUnread(message.channel); + } + } }); connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => { diff --git a/src/pages/Messages.test.tsx b/src/pages/Messages.test.tsx new file mode 100644 index 00000000..1ad82b4d --- /dev/null +++ b/src/pages/Messages.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, vi, expect } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MessagesPage } from "./Messages.tsx"; +import { useDevice } from "../core/stores/deviceStore"; +import { Protobuf } from "@meshtastic/core"; + +vi.mock("../core/stores/deviceStore", () => ({ + useDevice: vi.fn() +})); + +const mockUseDevice = { + channels: new Map([ + [0, { + index: 0, + settings: { name: "Primary" }, + role: Protobuf.Channel.Channel_Role.PRIMARY + }] + ]), + nodes: new Map([ + [0, { + num: 0, + user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" } + }], + [1111, { + num: 1111, + user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" } + }], + [2222, { + num: 2222, + user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" } + }], + [3333, { + num: 3333, + user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" } + }] + ]), + hardware: { myNodeNum: 1 }, + messages: { broadcast: new Map(), direct: new Map() }, + metadata: new Map(), + unreadCounts: new Map([[1111, 3], [2222, 10]]), + resetUnread: vi.fn(), + hasNodeError: vi.fn() +}; + + +describe("Messages Page", () => { + beforeEach(() => { + vi.mocked(useDevice).mockReturnValue(mockUseDevice); + }); + + it("sorts unreads to the top", () => { + render(); + const buttonOrder = screen.getAllByRole("button").filter(b => b.textContent.includes("Test Node")); + expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); + expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); + expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); + expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); + }); + + it("updates unread when active chat changes",() => { + render(); + const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 13"))[0]; + fireEvent.click(nodeButton); + expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); + }); + + it("does not update the incorrect node", async () => { + render(); + const nodeButton = screen.getAllByRole("button").filter(b => b.textContent.includes("TN1Test Node 1"))[0]; + fireEvent.click(nodeButton); + expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); + expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); + }); +}); \ No newline at end of file diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 82678241..a191044b 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -15,17 +15,27 @@ import { MessageInput } from "@components/PageComponents/Messages/MessageInput.t import { cn } from "@core/utils/cn.ts"; import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; +type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; + export const MessagesPage = () => { - const { channels, nodes, hardware, hasNodeError } = useDevice(); + const { channels, nodes, hardware, hasNodeError, unreadCounts, resetUnread } = useDevice(); const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore() const { toast } = useToast(); const [searchTerm, setSearchTerm] = useState(""); - const filteredNodes = Array.from(nodes.values()).filter((node) => { - if (node.num === hardware.myNodeNum) return false; - const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; - return nodeName.toLowerCase().includes(searchTerm.toLowerCase()); - }); + const filteredNodes: NodeInfoWithUnread[] = Array.from(nodes.values()) + .filter((node) => node.num !== hardware.myNodeNum) + .map((node) => ({ + ...node, + unreadCount: unreadCounts.get(node.num) ?? 0, + })) + .filter((node) => { + const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`; + return nodeName.toLowerCase().includes(searchTerm.toLowerCase()); + }) + .sort((a, b) => b.unreadCount - a.unreadCount); + + const allChannels = Array.from(channels.values()); const filteredChannels = allChannels.filter( (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, @@ -48,45 +58,45 @@ export const MessagesPage = () => { {filteredChannels.map((channel) => ( { setChatType(MessageType.Broadcast); setActiveChat(channel.index); + resetUnread(channel.index); }} element={} /> ))} -
+
setSearchTerm(e.target.value)} - className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900" + className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" />
-
- {filteredNodes.map((otherNode) => ( +
+ {filteredNodes.map((node) => ( 0 ? node.unreadCount : undefined} + active={activeChat === node.num && chatType === MessageType.Direct} onClick={() => { setChatType(MessageType.Direct); - setActiveChat(otherNode.num); + setActiveChat(node.num); + resetUnread(node.num); }} element={ } @@ -98,26 +108,22 @@ export const MessagesPage = () => {
{
)} + + {!isBroadcast && !isDirect && ( +
+ Select a channel or node to start messaging. +
+ )}
- + {(isBroadcast || isDirect) ? ( + + ) : ( +
Select a chat to send a message.
+ )}
diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index 389bf0d0..371e53c3 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes.tsx @@ -24,10 +24,9 @@ function shortNameFromNode( ): string { const shortNameOfNode = node.user?.shortName ?? (node.user?.macaddr - ? `${ - base16 - .stringify(node.user?.macaddr.subarray(4, 6) ?? []) - .toLowerCase() + ? `${base16 + .stringify(node.user?.macaddr.subarray(4, 6) ?? []) + .toLowerCase() }` : `${numberToHexUnpadded(node.num).slice(-4)}`); return String(shortNameOfNode); @@ -93,7 +92,7 @@ const NodesPage = (): JSX.Element => { placeholder="Search nodes..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} - className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900" + className="w-full p-2 border border-slate-300 rounded-sm bg-white text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500" />
@@ -124,10 +123,9 @@ const NodesPage = (): JSX.Element => { > {node.user?.longName ?? (node.user?.macaddr - ? `Meshtastic ${ - base16 - .stringify(node.user?.macaddr.subarray(4, 6) ?? []) - .toLowerCase() + ? `Meshtastic ${base16 + .stringify(node.user?.macaddr.subarray(4, 6) ?? []) + .toLowerCase() }` : `!${numberToHexUnpadded(node.num)}`)} , @@ -135,8 +133,7 @@ const NodesPage = (): JSX.Element => { {node.lastHeard !== 0 ? node.viaMqtt === false && node.hopsAway === 0 ? "Direct" - : `${node.hopsAway?.toString()} ${ - node.hopsAway > 1 ? "hops" : "hop" + : `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop" } away` : "-"} {node.viaMqtt === true ? ", via MQTT" : ""}