diff --git a/packages/core/src/utils/eventSystem.ts b/packages/core/src/utils/eventSystem.ts index f5f6afbf..81b24d34 100644 --- a/packages/core/src/utils/eventSystem.ts +++ b/packages/core/src/utils/eventSystem.ts @@ -330,6 +330,15 @@ export class EventSystem { PacketMetadata > = new SimpleEventDispatcher>(); + /** + * Fires when a new MeshPacket message containing a ClientNotification packet has been + * received from device + * + * @event onClientNotificationPacket + */ + public readonly onClientNotificationPacket: SimpleEventDispatcher = + new SimpleEventDispatcher(); + /** * Fires when the devices connection or configuration status changes * diff --git a/packages/core/src/utils/transform/decodePacket.ts b/packages/core/src/utils/transform/decodePacket.ts index f1f2eb09..94f0e54d 100644 --- a/packages/core/src/utils/transform/decodePacket.ts +++ b/packages/core/src/utils/transform/decodePacket.ts @@ -209,6 +209,18 @@ export const decodePacket = (device: MeshDevice) => break; } + case "clientNotification": { + device.log.trace( + Types.Emitter[Types.Emitter.HandleFromRadio], + `📣 Received ClientNotification: ${decodedMessage.payloadVariant.value.message}`, + ); + + device.events.onClientNotificationPacket.dispatch( + decodedMessage.payloadVariant.value, + ); + break; + } + default: { device.log.warn( Types.Emitter[Types.Emitter.HandleFromRadio], diff --git a/packages/web/public/i18n/locales/en/dialog.json b/packages/web/public/i18n/locales/en/dialog.json index feec9ebb..73a869d5 100644 --- a/packages/web/public/i18n/locales/en/dialog.json +++ b/packages/web/public/i18n/locales/en/dialog.json @@ -184,5 +184,10 @@ "confirmUnderstanding": "Yes, I know what I'm doing", "title": "Are you sure?", "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration." + }, + "clientNotification": { + "title": "Client Notification", + "TraceRoute can only be sent once every 30 seconds": "TraceRoute can only be sent once every 30 seconds", + "Compromised keys were detected and regenerated.": "Compromised keys were detected and regenerated." } } diff --git a/packages/web/src/components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx b/packages/web/src/components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx new file mode 100644 index 00000000..92d68531 --- /dev/null +++ b/packages/web/src/components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx @@ -0,0 +1,72 @@ +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { useDevice } from "@core/stores/deviceStore.ts"; +import { useTranslation } from "react-i18next"; + +export interface ClientNotificationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const ClientNotificationDialog = ({ + open, + onOpenChange, +}: ClientNotificationDialogProps) => { + const { t } = useTranslation("dialog"); + const { getClientNotification, removeClientNotification } = useDevice(); + + const localOnOpenChange = (open: boolean) => { + removeClientNotification(0); + if (!getClientNotification(0)) { + onOpenChange(open); + } + }; + + const dialogContent = (() => { + if (!getClientNotification(0)) { + return; + } + + switch (getClientNotification(0)?.payloadVariant.case) { + // TODO: Add KeyVerification logic + /*case "keyVerificationNumberInform": + return <>; + case "keyVerificationNumberRequest": + return <>; + case "keyVerificationFinal": + return <>; + case "duplicatedPublicKey": + return <>; + case "lowEntropyKey": + return <>;*/ + + default: + return ( + + {t("clientNotification.title")} + + {t([ + `clientNotification.${getClientNotification(0)?.message}`, + getClientNotification(0)?.message ?? "", + ])} + + + ); + } + })(); + + return ( + + + + {dialogContent} + + + ); +}; diff --git a/packages/web/src/components/Dialog/DialogManager.tsx b/packages/web/src/components/Dialog/DialogManager.tsx index 73046bbe..0af89f83 100644 --- a/packages/web/src/components/Dialog/DialogManager.tsx +++ b/packages/web/src/components/Dialog/DialogManager.tsx @@ -1,3 +1,4 @@ +import { ClientNotificationDialog } from "@components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx"; import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx"; import { ImportDialog } from "@components/Dialog/ImportDialog.tsx"; @@ -84,6 +85,12 @@ export const DialogManager = () => { setDialogOpen("deleteMessages", open); }} /> + { + setDialogOpen("clientNotification", open); + }} + /> ); }; diff --git a/packages/web/src/core/stores/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore.mock.ts index 218dc37a..8fa05670 100644 --- a/packages/web/src/core/stores/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore.mock.ts @@ -44,7 +44,10 @@ export const mockDeviceStore: Device = { refreshKeys: false, deleteMessages: false, managedMode: false, + clientNotification: false, }, + clientNotifications: [], + setStatus: vi.fn(), setConfig: vi.fn(), setModuleConfig: vi.fn(), @@ -85,6 +88,9 @@ export const mockDeviceStore: Device = { sendAdminMessage: vi.fn(), updateFavorite: vi.fn(), updateIgnored: vi.fn(), + addClientNotification: vi.fn(), + removeClientNotification: vi.fn(), + getClientNotification: vi.fn(), getAllUnreadCount: vi.fn().mockReturnValue(0), getUnreadCount: vi.fn().mockReturnValue(0), }; diff --git a/packages/web/src/core/stores/deviceStore.ts b/packages/web/src/core/stores/deviceStore.ts index 1d33318e..9ee04008 100644 --- a/packages/web/src/core/stores/deviceStore.ts +++ b/packages/web/src/core/stores/deviceStore.ts @@ -62,7 +62,9 @@ export interface Device { refreshKeys: boolean; deleteMessages: boolean; managedMode: boolean; + clientNotification: boolean; }; + clientNotifications: Protobuf.Mesh.ClientNotification[]; setStatus: (status: Types.DeviceStatusEnum) => void; setConfig: (config: Protobuf.Config.Config) => void; @@ -125,6 +127,13 @@ export interface Device { sendAdminMessage: (message: Protobuf.Admin.AdminMessage) => void; updateFavorite: (nodeNum: number, isFavorite: boolean) => void; updateIgnored: (nodeNum: number, isIgnored: boolean) => void; + addClientNotification: ( + clientNotificationPacket: Protobuf.Mesh.ClientNotification, + ) => void; + removeClientNotification: (index: number) => void; + getClientNotification: ( + index: number, + ) => Protobuf.Mesh.ClientNotification | undefined; } export interface DeviceState { @@ -173,12 +182,14 @@ export const useDeviceStore = createStore((set, get) => ({ refreshKeys: false, deleteMessages: false, managedMode: false, + clientNotification: false, }, pendingSettingsChanges: false, messageDraft: "", nodeErrors: new Map(), unreadCounts: new Map(), nodesMap: new Map(), + clientNotifications: [], setStatus: (status: Types.DeviceStatusEnum) => { set( @@ -847,6 +858,37 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, + addClientNotification: ( + clientNotificationPacket: Protobuf.Mesh.ClientNotification, + ) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + device.clientNotifications.push(clientNotificationPacket); + }), + ); + }, + removeClientNotification: (index: number) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + device.clientNotifications.splice(index, 1); + }), + ); + }, + getClientNotification: (index: number) => { + const device = get().devices.get(id); + if (!device) { + return; + } + return device.clientNotifications[index]; + }, }); }), ); diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 338512c9..0f685f4e 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -114,6 +114,13 @@ export const subscribeAll = ( }); }); + connection.events.onClientNotificationPacket.subscribe( + (clientNotificationPacket) => { + device.addClientNotification(clientNotificationPacket); + device.setDialogOpen("clientNotification", true); + }, + ); + connection.events.onRoutingPacket.subscribe((routingPacket) => { if (routingPacket.data.variant.case === "errorReason") { switch (routingPacket.data.variant.value) {