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 => ( ); 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)} /> ))}