diff --git a/public/notification.wav b/public/notification.wav
new file mode 100644
index 00000000..1e9140fa
Binary files /dev/null and b/public/notification.wav differ
diff --git a/src/components/DeviceSelector.tsx b/src/components/DeviceSelector.tsx
index 42be3ecf..bc4d6308 100644
--- a/src/components/DeviceSelector.tsx
+++ b/src/components/DeviceSelector.tsx
@@ -5,12 +5,15 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Hashicon } from "@emeraldpay/hashicon-react";
import {
+ BellIcon,
+ BellOffIcon,
HomeIcon,
LanguagesIcon,
MoonIcon,
PlusIcon,
SearchIcon,
SunIcon,
+ TerminalIcon,
} from "lucide-react";
export const DeviceSelector = (): JSX.Element => {
@@ -20,6 +23,8 @@ export const DeviceSelector = (): JSX.Element => {
setSelectedDevice,
darkMode,
setDarkMode,
+ notifications,
+ setNotifications,
setCommandPaletteOpen,
setConnectDialogOpen,
} = useAppStore();
@@ -61,6 +66,13 @@ export const DeviceSelector = (): JSX.Element => {
+
-
+
+
{pages.map((link) => (
{
setActivePage(link.page);
}}
active={link.page === activePage}
+ unread={link.name == "Messages" ? hasUnread(null, null) : false}
/>
))}
diff --git a/src/components/UI/Sidebar/sidebarButton.tsx b/src/components/UI/Sidebar/sidebarButton.tsx
index c3f30e79..cb459030 100644
--- a/src/components/UI/Sidebar/sidebarButton.tsx
+++ b/src/components/UI/Sidebar/sidebarButton.tsx
@@ -1,11 +1,12 @@
import { Button } from "@components/UI/Button.tsx";
-import type { LucideIcon } from "lucide-react";
+import {LucideMessageSquareDot, type LucideIcon } from "lucide-react";
export interface SidebarButtonProps {
label: string;
active?: boolean;
Icon?: LucideIcon;
element?: JSX.Element;
+ unread: boolean;
onClick?: () => void;
}
@@ -14,6 +15,7 @@ export const SidebarButton = ({
active,
Icon,
element,
+ unread,
onClick,
}: SidebarButtonProps): JSX.Element => (
}
{element && element}
{label}
+ {unread ?
+
+ : null
+ }
);
diff --git a/src/core/stores/appStore.ts b/src/core/stores/appStore.ts
index c7bc2c62..315d908d 100644
--- a/src/core/stores/appStore.ts
+++ b/src/core/stores/appStore.ts
@@ -26,6 +26,7 @@ interface AppState {
rasterSources: RasterSource[];
commandPaletteOpen: boolean;
darkMode: boolean;
+ notifications: boolean;
nodeNumToBeRemoved: number;
accent: AccentColor;
connectDialogOpen: boolean;
@@ -39,6 +40,7 @@ interface AppState {
removeDevice: (deviceId: number) => void;
setCommandPaletteOpen: (open: boolean) => void;
setDarkMode: (enabled: boolean) => void;
+ setNotifications: (enabled: boolean) => void;
setNodeNumToBeRemoved: (nodeNum: number) => void;
setAccent: (color: AccentColor) => void;
setConnectDialogOpen: (open: boolean) => void;
@@ -50,6 +52,7 @@ export const useAppStore = create()((set) => ({
currentPage: "messages",
rasterSources: [],
commandPaletteOpen: false,
+ notifications: true,
darkMode:
localStorage.getItem("theme-dark") !== null
? localStorage.getItem("theme-dark") === "true"
@@ -106,6 +109,13 @@ export const useAppStore = create()((set) => ({
}),
);
},
+ setNotifications: (enabled: boolean) => {
+ set(
+ produce((draft) => {
+ draft.notifications = enabled;
+ }),
+ );
+ },
setNodeNumToBeRemoved: (nodeNum) =>
set((state) => ({
nodeNumToBeRemoved: nodeNum,
diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts
index a716a85b..3039cfa2 100644
--- a/src/core/stores/deviceStore.ts
+++ b/src/core/stores/deviceStore.ts
@@ -9,6 +9,7 @@ export type Page = "messages" | "map" | "config" | "channels" | "nodes";
export interface MessageWithState extends Types.PacketMetadata {
state: MessageState;
+ unread: boolean;
}
export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error;
@@ -95,6 +96,8 @@ export interface Device {
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
processPacket: (data: ProcessPacketParams) => void;
setMessageDraft: (message: string) => void;
+ hasUnread: (channel: number | null, nodeNum: number | null) => boolean;
+ markAllRead: (channel: number | null, nodeNum: number | null) => void;
}
export interface DeviceState {
@@ -112,7 +115,7 @@ export const useDeviceStore = create((set, get) => ({
remoteDevices: new Map(),
addDevice: (id: number) => {
- set(
+ set(
produce((draft) => {
draft.devices.set(id, {
id,
@@ -312,10 +315,10 @@ export const useDeviceStore = create((set, get) => ({
},
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set(
- produce((draft) => {
- const device = draft.devices.get(id);
+ produce((draft) => {
+ const device = draft.devices.get(id);
if (device) {
- device.hardware = hardware;
+ device.hardware = hardware;
}
}),
);
@@ -392,6 +395,30 @@ export const useDeviceStore = create((set, get) => ({
return;
}
device.channels.set(channel.index, channel);
+
+ const messageGroup = device.messages["broadcast"];
+
+ let msgJson = localStorage.getItem("msg_" + channel.index)
+ if (msgJson !== null) {
+ let storedMsgs = JSON.parse(msgJson)
+ for (let a = 0; a < storedMsgs.length; a++) {
+ let message = storedMsgs[a]
+ if (channel.index == 0) {
+ message.unread = false
+ }
+ message.rxTime = new Date(message.rxTime)
+ message.state = "ack"
+ let messageIndex = message.channel
+ let messages = messageGroup.get(messageIndex);
+ if (messages === undefined) {
+ messages = [message]
+ } else {
+ messages.push(message);
+ }
+ messageGroup.set(messageIndex, messages)
+ }
+ }
+
}),
);
},
@@ -421,6 +448,26 @@ export const useDeviceStore = create((set, get) => ({
return;
}
device.nodes.set(nodeInfo.num, nodeInfo);
+
+ const messageGroup = device.messages["direct"];
+
+ let msgJson = localStorage.getItem("msg_" + nodeInfo.num)
+ if (msgJson !== null) {
+ let storedMsgs = JSON.parse(msgJson)
+ for (let a = 0; a < storedMsgs.length; a++) {
+ let message = storedMsgs[a]
+ message.rxTime = new Date(message.rxTime)
+ message.state = "ack"
+ let messageIndex = (message.from === device.hardware.myNodeNum) ? message.to : message.from
+ let messages = messageGroup.get(messageIndex);
+ if (messages === undefined) {
+ messages = [message]
+ } else {
+ messages.push(message);
+ }
+ messageGroup.set(messageIndex, messages)
+ }
+ }
}),
);
},
@@ -474,9 +521,10 @@ export const useDeviceStore = create((set, get) => ({
);
},
addMessage: (message) => {
+
set(
produce((draft) => {
- const device = draft.devices.get(id);
+ let device = draft.devices.get(id);
if (!device) {
return;
}
@@ -487,14 +535,19 @@ export const useDeviceStore = create((set, get) => ({
? message.to
: message.from
: message.channel;
- const messages = messageGroup.get(messageIndex);
+ let messages = messageGroup.get(messageIndex);
- if (messages) {
- messages.push(message);
- messageGroup.set(messageIndex, messages);
+ if (message.from !== device.hardware.myNodeNum) {
+ message.unread = true;
+ }
+
+ if (messages === undefined) {
+ messages = [message];
} else {
- messageGroup.set(messageIndex, [message]);
+ messages.push(message);
}
+ messageGroup.set(messageIndex, messages);
+ localStorage.setItem("msg_" + messageIndex, JSON.stringify(messages))
}),
);
},
@@ -513,8 +566,8 @@ export const useDeviceStore = create((set, get) => ({
addTraceRoute: (traceroute) => {
set(
produce((draft) => {
- console.log("addTraceRoute called");
- console.log(traceroute);
+ //console.log("addTraceRoute called");
+ //console.log(traceroute);
const device = draft.devices.get(id);
if (!device) {
return;
@@ -632,6 +685,94 @@ export const useDeviceStore = create((set, get) => ({
}),
);
},
+ hasUnread: (channel: number | null, nodeNum: number | null) => {
+ const device = get().devices.get(id);
+
+ if (device == null) {
+ return false
+ }
+
+ if (channel == null && nodeNum == null) {
+ let channelsWithMessages = device.messages["broadcast"]
+ for (let [channelIndex, messages] of channelsWithMessages) {
+ for (let msg of messages) {
+ if (msg.unread) {
+ return true
+ }
+ }
+ }
+ channelsWithMessages = device.messages["direct"]
+ for (let [nodeIndex, messages] of channelsWithMessages) {
+ for (let msg of messages) {
+ if (msg.unread) {
+ return true
+ }
+ }
+ }
+ }
+
+ if (channel !== null) {
+ const channelsWithMessages = device.messages["broadcast"]
+ let messages = channelsWithMessages.get(channel)
+ if (messages == null) {
+ return false
+ }
+ for (let msg of messages) {
+ if (msg.unread) {
+ return true
+ }
+ }
+ }
+
+ if (nodeNum !== null) {
+ const channelsWithMessages = device.messages["direct"]
+ let messages = channelsWithMessages.get(nodeNum)
+ if (messages == null) {
+ return false
+ }
+ for (let msg of messages) {
+ if (msg.unread) {
+ return true
+ }
+ }
+ }
+
+ return false
+ },
+ markAllRead: (channel: number | null, nodeNum: number | null) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (device == null) {
+ return
+ }
+ if (channel !== null) {
+ const channelsWithMessages = device.messages["broadcast"]
+ let messages = channelsWithMessages.get(channel)
+ if (messages == null) {
+ return
+ }
+ for (let msg of messages) {
+ msg.unread = false
+ }
+ channelsWithMessages.set(channel, messages);
+ localStorage.setItem("msg_" + channel, JSON.stringify(messages))
+ }
+ if (nodeNum !== null) {
+ const directsWithMessages = device.messages["direct"]
+ let messages = directsWithMessages.get(nodeNum)
+ if (messages == null) {
+ return
+ }
+ for (let msg of messages) {
+ msg.unread = false
+ }
+ directsWithMessages.set(nodeNum, messages);
+ localStorage.setItem("msg_" + nodeNum, JSON.stringify(messages))
+ }
+ })
+ )
+ }
});
}),
);
@@ -654,6 +795,7 @@ export const useDeviceStore = create((set, get) => ({
getDevices: () => Array.from(get().devices.values()),
getDevice: (id) => get().devices.get(id),
+
}));
export const DeviceContext = createContext(undefined);
diff --git a/src/core/subscriptions.ts b/src/core/subscriptions.ts
index afba12b7..41631678 100644
--- a/src/core/subscriptions.ts
+++ b/src/core/subscriptions.ts
@@ -1,5 +1,6 @@
import type { Device } from "@core/stores/deviceStore.ts";
import { Protobuf, type Types } from "@meshtastic/js";
+import { playNotificationSound } from "./utils/notify";
export const subscribeAll = (
device: Device,
@@ -84,6 +85,9 @@ export const subscribeAll = (
...messagePacket,
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
});
+ if (messagePacket.from !== myNodeNum) {
+ playNotificationSound();
+ }
});
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
diff --git a/src/core/utils/notify.ts b/src/core/utils/notify.ts
new file mode 100644
index 00000000..adf2f10e
--- /dev/null
+++ b/src/core/utils/notify.ts
@@ -0,0 +1,17 @@
+import { useAppStore } from "@core/stores/appStore.ts";
+
+const notificationSound = new Audio("/notification.wav"); //change sound if needed
+
+let isPlaying = false;
+
+export const playNotificationSound = () => {
+ const { notifications } = useAppStore.getState();
+ if (notifications && !isPlaying) {
+ isPlaying = true;
+ notificationSound.play();
+
+ notificationSound.onended = () => {
+ isPlaying = false;
+ };
+ }
+};
diff --git a/src/index.css b/src/index.css
index 8a35066d..f173eb20 100644
--- a/src/index.css
+++ b/src/index.css
@@ -100,3 +100,11 @@ img {
-drag: none;
-webkit-user-drag: none;
}
+
+.notifyBadge {
+ background-color: #f25555;
+ border-radius: 50%;
+ padding:5px;
+ color: #ffffff;
+ font-weight: bold;
+}
\ No newline at end of file
diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx
index 28a35d2c..39a5c8ac 100644
--- a/src/pages/Messages.tsx
+++ b/src/pages/Messages.tsx
@@ -15,6 +15,7 @@ import { useState } from "react";
export const MessagesPage = (): JSX.Element => {
const { channels, nodes, hardware, messages, traceroutes, connection } =
useDevice();
+ const { hasUnread, markAllRead } = useDevice();
const [chatType, setChatType] =
useState("broadcast");
const [activeChat, setActiveChat] = useState(
@@ -48,10 +49,12 @@ export const MessagesPage = (): JSX.Element => {
}
active={activeChat === channel.index}
onClick={() => {
+ markAllRead(channel.index, null)
setChatType("broadcast");
setActiveChat(channel.index);
}}
element={}
+ unread={hasUnread(channel.index, null)}
/>
))}
@@ -62,10 +65,12 @@ export const MessagesPage = (): JSX.Element => {
label={node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`}
active={activeChat === node.num}
onClick={() => {
+ markAllRead(null, node.num)
setChatType("direct");
setActiveChat(node.num);
}}
element={}
+ unread={hasUnread(null, node.num)}
/>
))}