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" : ""}