From 45a26548451f8406668273d718a356ed45c6c26b Mon Sep 17 00:00:00 2001 From: Mikol Graves Date: Fri, 16 Sep 2022 09:53:59 -0700 Subject: [PATCH] client: Read notify search param and post notifications when true (#32) --- package.json | 2 +- src/v2/components/PotentialFireList.jsx | 101 +++++++++++++++++++++--- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 1f15614..e22acdb 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "src/v2/**/*.mjs" ], "rules": { - "no-console": "error" + "no-console": "warn" } } ] diff --git a/src/v2/components/PotentialFireList.jsx b/src/v2/components/PotentialFireList.jsx index 70efa6c..f2864a2 100644 --- a/src/v2/components/PotentialFireList.jsx +++ b/src/v2/components/PotentialFireList.jsx @@ -24,6 +24,7 @@ // - Implement adaptive/mobile version. import React, {useCallback, useEffect, useRef, useState} from 'react' +import Notification from 'react-web-notification' import Duration from '../modules/Duration.mjs' @@ -35,6 +36,14 @@ import parseRegion from '../modules/parseRegion.mjs' import FireList from './FireList.jsx' +const ShouldNotify = { + MIN_INTERVAL_SECONDS: 30, + MAX_RECENT_NOTIFICATIONS: 2, + MAX_RECENT_SECONDS: 5 * 60, + // XXX: Used to instantiate empty Notification to track window activity. + NO_NOTIFICATION: {} +} + const TIMESTAMP_LIMIT = 2 * Duration.HOUR const {error: report} = console @@ -52,6 +61,8 @@ export default function PotentialFireList(props) { const [includesAllFires, setIncludesAllFires] = useState(false) const [indexOfOldFires, setIndexOfOldFires] = useState(-1) const [region, setRegion] = useState(null) + const [shouldNotify, setShouldNotify] = useState(false) + const [notification, setNotification] = useState(ShouldNotify.NO_NOTIFICATION) const allFiresRef = useRef([]) const eventSourceRef = useRef() @@ -69,6 +80,56 @@ export default function PotentialFireList(props) { setIndexOfOldFires(index) }, []) + const handleNotification = useCallback(() => { + if (!shouldNotify) { + return setNotification(ShouldNotify.NO_NOTIFICATION) + } + + const {current: allFires} = allFiresRef + const fire = allFires[0] + const { + camInfo: {cameraName, cameraDir = 'UNKNOWN'}, + isRealTime, notified = false, timestamp + } = fire + + if (notified || !isRealTime) { + return report('Failed precondition: `fire` should be real-time and shouldn’t be notified') + } + + // ------------------------------------------------------------------------- + // Prevent notifications from appearing too frequently. + + const notifiedFires = allFires.filter((x) => x.isRealTime && x.notified) + const mru = notifiedFires.length > 0 ? notifiedFires[0].timestamp : 0 + + // At least MIN_INTERVAL_SECONDS between notifications. + if (timestamp - mru <= ShouldNotify.MIN_INTERVAL_SECONDS) { + return setNotification(ShouldNotify.NO_NOTIFICATION) + } + + const timestampLimit = timestamp - ShouldNotify.MAX_RECENT_SECONDS + const recentlyNotifiedFires = notifiedFires.filter((x) => x.timestamp > timestampLimit) + + // At most MAX_RECENT_NOTIFICATIONS every MAX_RECENT_SECONDS. + if (recentlyNotifiedFires.length >= ShouldNotify.MAX_RECENT_NOTIFICATIONS) { + return setNotification(ShouldNotify.NO_NOTIFICATION) + } + + // ------------------------------------------------------------------------- + + fire.notified = true + + setNotification({ + title: 'Potential fire', + options: { + body: `Camera ${cameraName} facing ${cameraDir}`, + icon: '/wildfirecheck/checkfire192.png', + lang: 'en', + tag: `${timestamp}` + } + }) + }, [shouldNotify]) + const handleToggleAllFires = useCallback(() => { const shouldIncludeAllFires = !includesAllFires setIncludesAllFires(shouldIncludeAllFires) @@ -77,7 +138,7 @@ export default function PotentialFireList(props) { const handlePotentialFire = useCallback((event) => { const fire = JSON.parse(event.data) - const {croppedUrl, polygon, version} = fire + const {cameraID, croppedUrl, polygon, timestamp, version} = fire if (region != null && !isPolygonWithinRegion(polygon, region)) { return false @@ -113,14 +174,28 @@ export default function PotentialFireList(props) { allFires.unshift(fire) allFires.sort((a, b) => b.sortId - a.sortId) + const first = allFires[0] + if (first != null) { + if (first.isRealTime && first.timestamp === timestamp && first.cameraID === cameraID) { + handleNotification() + } + } + updateFires(includesAllFires) } - }, [includesAllFires, region, updateFires]) + }, [handleNotification, includesAllFires, region, updateFires]) useEffect(() => { const searchParams = new URLSearchParams(window.location.search) + const notifyParam = searchParams.get('notify') const regionParam = searchParams.get('latLong') + if (notifyParam != null) { + if (/true|false/.test(notifyParam)) { + setShouldNotify(notifyParam === 'true') + } + } + if (regionParam != null) { try { setRegion(parseRegion(regionParam)) @@ -154,13 +229,17 @@ export default function PotentialFireList(props) { return tidy }, [handlePotentialFire]) - return -1 ? allFiresRef.current.length - indexOfOldFires : 0} - onToggleAllFires={handleToggleAllFires} - region={region} - updateFires={updateFires} - {...props}/> + return 0, + <> + + -1 ? allFiresRef.current.length - indexOfOldFires : 0} + onToggleAllFires={handleToggleAllFires} + region={region} + updateFires={updateFires} + {...props}/> + }