From 9a8603e5cd1a76042800767b0a112497c7bda1ca Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Thu, 16 Apr 2026 10:15:00 +0100 Subject: [PATCH] Extract web app client controller helpers --- WebApp/src/lib/app/controller-client.js | 180 ++++++++++++++++++++++++ WebApp/src/lib/app/controller.js | 144 +++---------------- 2 files changed, 200 insertions(+), 124 deletions(-) create mode 100644 WebApp/src/lib/app/controller-client.js diff --git a/WebApp/src/lib/app/controller-client.js b/WebApp/src/lib/app/controller-client.js new file mode 100644 index 0000000..442247d --- /dev/null +++ b/WebApp/src/lib/app/controller-client.js @@ -0,0 +1,180 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck + +export const createControllerClientModule = ({ + api, + getAppState, + setAppState, + patchAppState, + makeId +}) => { + const getLinkedCamera = (cameraDeviceId) => + getAppState().linkedCameras.find((camera) => camera.cameraDeviceId === cameraDeviceId); + + const getCameraLabel = (cameraDeviceId, cameraName) => { + const explicitName = typeof cameraName === 'string' ? cameraName.trim() : ''; + if (explicitName) return explicitName; + + const linkedName = getLinkedCamera(cameraDeviceId)?.cameraName; + if (typeof linkedName === 'string' && linkedName.trim()) { + return linkedName.trim(); + } + + return `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`; + }; + + const mapNotificationDeliveryToMotionNotification = (delivery) => { + const payload = delivery?.payload ?? {}; + const cameraDeviceId = typeof payload.cameraDeviceId === 'string' ? payload.cameraDeviceId : ''; + const deliveryType = typeof delivery?.type === 'string' ? delivery.type : ''; + + if (deliveryType !== 'motion_detected') { + return null; + } + + return { + id: delivery.id, + eventId: typeof payload.eventId === 'string' ? payload.eventId : null, + cameraDeviceId, + message: `${getCameraLabel(cameraDeviceId)} has detected movement`, + createdAt: delivery.sentAt || delivery.createdAt || new Date().toISOString(), + isRead: delivery.status === 'read' + }; + }; + + const syncMotionNotificationsFromDeliveries = (deliveries) => { + const motionNotifications = (deliveries || []) + .map(mapNotificationDeliveryToMotionNotification) + .filter(Boolean) + .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()); + + setAppState({ motionNotifications }); + }; + + const refreshMotionNotifications = async () => { + const { device } = getAppState(); + if (!device || device.role !== 'client') { + setAppState({ motionNotifications: [] }); + return []; + } + + const result = await api.ops.listNotifications().catch(() => ({ notifications: [] })); + syncMotionNotificationsFromDeliveries(result.notifications); + return result.notifications || []; + }; + + const pushMotionNotification = (cameraDeviceId) => { + if (!cameraDeviceId) return; + + const notification = { + id: makeId(), + eventId: null, + cameraDeviceId, + message: `${getCameraLabel(cameraDeviceId)} has detected movement`, + createdAt: new Date().toISOString(), + isRead: false + }; + + patchAppState((state) => ({ + motionNotifications: [notification, ...state.motionNotifications].slice(0, 50) + })); + + void refreshMotionNotifications(); + }; + + const markMotionNotificationRead = async (notificationId) => { + patchAppState((state) => ({ + motionNotifications: state.motionNotifications.map((notification) => + notification.id === notificationId ? { ...notification, isRead: true } : notification + ) + })); + + try { + await api.pushNotifications.markRead(notificationId); + } catch (error) { + console.error('Failed to persist notification read state', error); + } + }; + + const markAllNotificationsRead = async () => { + const unreadIds = getAppState().motionNotifications + .filter((notification) => !notification.isRead) + .map((notification) => notification.id); + + if (unreadIds.length === 0) { + return; + } + + patchAppState((state) => ({ + motionNotifications: state.motionNotifications.map((notification) => + notification.isRead ? notification : { ...notification, isRead: true } + ) + })); + + await Promise.allSettled(unreadIds.map((notificationId) => api.pushNotifications.markRead(notificationId))); + }; + + const openRecordingModal = (downloadUrl, title) => { + setAppState({ + recordingModal: { + open: true, + title: title || 'Recording Playback', + url: downloadUrl + } + }); + }; + + const closeRecordingModal = () => { + setAppState({ + recordingModal: { + open: false, + title: 'Recording Playback', + url: '' + } + }); + }; + + const pollClientData = async () => { + const { device } = getAppState(); + if (!device || device.role !== 'client') return; + + const [recs, links, deviceList, notifications] = await Promise.all([ + api.ops.listRecordings().catch(() => ({ recordings: [] })), + api.devices.listLinks().catch(() => ({ links: [] })), + api.devices.list().catch(() => ({ devices: [] })), + api.ops.listNotifications().catch(() => ({ notifications: [] })) + ]); + + const cameraById = new Map( + (deviceList.devices || []) + .filter((entry) => entry.role === 'camera') + .map((entry) => [entry.id, entry]) + ); + + const linkedCameras = (links.links || []).map((link) => { + const camera = cameraById.get(link.cameraDeviceId); + return { + ...link, + cameraName: camera?.name ?? null, + cameraStatus: camera?.status ?? 'offline' + }; + }); + + setAppState({ + recordings: recs.recordings || [], + linkedCameras + }); + syncMotionNotificationsFromDeliveries(notifications.notifications); + }; + + return { + getLinkedCamera, + getCameraLabel, + markMotionNotificationRead, + markAllNotificationsRead, + pushMotionNotification, + openRecordingModal, + closeRecordingModal, + pollClientData + }; +}; diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js index 9709687..d2c431d 100644 --- a/WebApp/src/lib/app/controller.js +++ b/WebApp/src/lib/app/controller.js @@ -12,6 +12,7 @@ import { getMotionDetectionProfile, pageFromPath } from './controller-shared'; +import { createControllerClientModule } from './controller-client'; import { createControllerMediaModule } from './controller-media'; import { getAppState, patchAppState, resetAppState, setAppState } from './store'; @@ -86,6 +87,14 @@ const { patchAppState }); +const clientController = createControllerClientModule({ + api, + getAppState, + setAppState, + patchAppState, + makeId +}); + const mediaController = createControllerMediaModule({ api, createMotionDetector, @@ -98,6 +107,17 @@ const mediaController = createControllerMediaModule({ updateMotionDetectionState }); +const { + getLinkedCamera, + getCameraLabel, + markMotionNotificationRead, + markAllNotificationsRead, + pushMotionNotification, + openRecordingModal, + closeRecordingModal, + pollClientData +} = clientController; + const { refreshCameraInputDevices, startCameraPreview, @@ -275,130 +295,6 @@ const removeCameraSessionMapping = (streamSessionId) => { })); }; -const getLinkedCamera = (cameraDeviceId) => - getAppState().linkedCameras.find((camera) => camera.cameraDeviceId === cameraDeviceId); - -const getCameraLabel = (cameraDeviceId, cameraName) => { - const explicitName = typeof cameraName === 'string' ? cameraName.trim() : ''; - if (explicitName) return explicitName; - - const linkedName = getLinkedCamera(cameraDeviceId)?.cameraName; - if (typeof linkedName === 'string' && linkedName.trim()) { - return linkedName.trim(); - } - - return `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`; -}; - -const mapNotificationDeliveryToMotionNotification = (delivery) => { - const payload = delivery?.payload ?? {}; - const cameraDeviceId = typeof payload.cameraDeviceId === 'string' ? payload.cameraDeviceId : ''; - const deliveryType = typeof delivery?.type === 'string' ? delivery.type : ''; - - if (deliveryType !== 'motion_detected') { - return null; - } - - return { - id: delivery.id, - eventId: typeof payload.eventId === 'string' ? payload.eventId : null, - cameraDeviceId, - message: `${getCameraLabel(cameraDeviceId)} has detected movement`, - createdAt: delivery.sentAt || delivery.createdAt || new Date().toISOString(), - isRead: delivery.status === 'read' - }; -}; - -const syncMotionNotificationsFromDeliveries = (deliveries) => { - const motionNotifications = (deliveries || []) - .map(mapNotificationDeliveryToMotionNotification) - .filter(Boolean) - .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()); - - setAppState({ motionNotifications }); -}; - -const refreshMotionNotifications = async () => { - const { device } = getAppState(); - if (!device || device.role !== 'client') { - setAppState({ motionNotifications: [] }); - return []; - } - - const result = await api.ops.listNotifications().catch(() => ({ notifications: [] })); - syncMotionNotificationsFromDeliveries(result.notifications); - return result.notifications || []; -}; - -const pushMotionNotification = (cameraDeviceId) => { - if (!cameraDeviceId) return; - - const notification = { - id: makeId(), - eventId: null, - cameraDeviceId, - message: `${getCameraLabel(cameraDeviceId)} has detected movement`, - createdAt: new Date().toISOString(), - isRead: false - }; - - patchAppState((state) => ({ - motionNotifications: [notification, ...state.motionNotifications].slice(0, 50) - })); - - void refreshMotionNotifications(); -}; - -const markMotionNotificationRead = async (notificationId) => { - patchAppState((state) => ({ - motionNotifications: state.motionNotifications.map((notification) => - notification.id === notificationId ? { ...notification, isRead: true } : notification - ) - })); - - try { - await api.pushNotifications.markRead(notificationId); - } catch (error) { - console.error('Failed to persist notification read state', error); - } -}; - -const markAllNotificationsRead = async () => { - const unreadIds = getAppState().motionNotifications.filter((notification) => !notification.isRead).map((notification) => notification.id); - - if (unreadIds.length === 0) { - return; - } - - patchAppState((state) => ({ - motionNotifications: state.motionNotifications.map((notification) => - notification.isRead ? notification : { ...notification, isRead: true } - ) - })); - - await Promise.allSettled(unreadIds.map((notificationId) => api.pushNotifications.markRead(notificationId))); -}; - -const openRecordingModal = (downloadUrl, title) => { - setAppState({ - recordingModal: { - open: true, - title: title || 'Recording Playback', - url: downloadUrl - } - }); -}; - -const closeRecordingModal = () => { - setAppState({ - recordingModal: { - open: false, - title: 'Recording Playback', - url: '' - } - }); -}; - const teardownPeerConnection = (streamSessionId) => { if (!streamSessionId) { for (const connection of peerConnections.values()) {