Skip to content

Commit

Permalink
More generic periodic notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
corrideat committed Dec 25, 2024
1 parent 6fea6bf commit 2d21c74
Showing 1 changed file with 87 additions and 35 deletions.
122 changes: 87 additions & 35 deletions frontend/model/notifications/periodicNotifications.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use strict'

import sbp from '@sbp/sbp'
import Vue from 'vue'
// $FlowFixMe
import { isFunction, objectOf, string } from '@model/contracts/misc/flowTyper.js'
import { MINS_MILLIS } from '@model/contracts/shared/time.js'
Expand All @@ -13,16 +12,13 @@ export const PERIODIC_NOTIFICATION_TYPE = {
MIN30: '30MIN'
}

const every1MinTimeout = { notifications: [], state: {}, delay: MINS_MILLIS }
const every5MinTimeout = { notifications: [], state: {}, delay: 5 * MINS_MILLIS }
const every15MinTimeout = { notifications: [], state: {}, delay: 15 * MINS_MILLIS }
const every30MinTimeout = { notifications: [], state: {}, delay: 30 * MINS_MILLIS }
const ephemeralNotificationState = { notifications: [], partition: Object.create(null) }

const typeToObjectMap = {
[PERIODIC_NOTIFICATION_TYPE.MIN1]: every1MinTimeout,
[PERIODIC_NOTIFICATION_TYPE.MIN5]: every5MinTimeout,
[PERIODIC_NOTIFICATION_TYPE.MIN15]: every15MinTimeout,
[PERIODIC_NOTIFICATION_TYPE.MIN30]: every30MinTimeout
const delayToObjectMap = {
[PERIODIC_NOTIFICATION_TYPE.MIN1]: 1 * MINS_MILLIS,
[PERIODIC_NOTIFICATION_TYPE.MIN5]: 5 * MINS_MILLIS,
[PERIODIC_NOTIFICATION_TYPE.MIN15]: 15 * MINS_MILLIS,
[PERIODIC_NOTIFICATION_TYPE.MIN30]: 30 * MINS_MILLIS
}

const validateNotificationData = objectOf({
Expand All @@ -38,59 +34,115 @@ const validateNotificationData = objectOf({
shouldClearStateKey: isFunction
})

async function runNotificationListRecursive (data) {
/**
* Runs a recursive notification list handler that checks and emits
* notifications based on specified conditions and delays.
*
* The function performs the following steps:
* 1. Checks if enough time has passed since the last run based on the specified
* delay.
* 2. Iterates over each notification entry in the `data.notifications` array:
* - If the notification has not been fired and its emit condition evaluates
* to true, it:
* - Calls the emit function associated with the notification.
* - Marks the notification as fired in the `firedMap`.
* - If the notification has been fired and its clear condition evaluates to
* true, it:
* - Removes the notification from the `firedMap`.
* 3. Updates the `lastRun` timestamp to the current time.
* 4. Sets a timeout to recursively call `runNotificationListRecursive` after
* the specified delay, adjusting for the time that has already passed since the
* last run.
*/
async function runNotificationListRecursive () {
const rootState = sbp('state/vuex/state')
const rootGetters = sbp('state/vuex/getters')
const firedMap = rootState.periodicNotificationAlreadyFiredMap
const callWithStates = func => func.call(data.state, { rootState, rootGetters })
const firedMap = rootState.periodicNotificationAlreadyFiredMap.alreadyFired
const lastRunMap = rootState.periodicNotificationAlreadyFiredMap.lastRun
const callWithStates = (func, stateKey) => func.call(ephemeralNotificationState.partition[stateKey], { rootState, rootGetters })

for (const entry of data.notifications) {
if (!firedMap[entry.stateKey] && callWithStates(entry.emitCondition)) {
await callWithStates(entry.emit)
Vue.set(firedMap, entry.stateKey, true)
}
// Exit if a timeout is already set
if (ephemeralNotificationState.clearTimeout) return

// Check if enough time has passed since the last run
for (const entry of ephemeralNotificationState.notifications) {
const lastRun = lastRunMap[entry.stateKey] || 0
if ((Date.now() - lastRun) >= ephemeralNotificationState.delay) {
try {
if (!firedMap[entry.stateKey] && callWithStates(entry.emitCondition, entry.stateKey)) {
await callWithStates(entry.emit, entry.stateKey)
firedMap[entry.stateKey] = true
}

if (firedMap[entry.stateKey] && callWithStates(entry.shouldClearStateKey, entry.stateKey)) {
delete firedMap[entry.stateKey]
}
} catch (e) {
console.error('runNotificationListRecursive: Error calling notification', entry.stateKey, e)
}

if (firedMap[entry.stateKey] && callWithStates(entry.shouldClearStateKey)) {
Vue.delete(firedMap, entry.stateKey)
// Update the last run timestamp
lastRunMap[entry.stateKey] = Date.now()
}
}

data.state.timeoutId = setTimeout(() => runNotificationListRecursive(data), data.delay)
// Set a timeout for the next run of the notification check
ephemeralNotificationState.clearTimeout = (() => {
const timeoutId = setTimeout(
() => {
delete ephemeralNotificationState.clearTimeout
runNotificationListRecursive()
},
1 * MINS_MILLIS
)
return () => clearTimeout(timeoutId)
})()
}

function clearTimeoutObject (data) {
clearTimeout(data.state.timeoutId)
data.state = {}
function clearTimeoutObject () {
ephemeralNotificationState.clearTimeout?.()
ephemeralNotificationState.state = Object.create(null)
ephemeralNotificationState.notifications.forEach(({ stateKey }) => {
ephemeralNotificationState.partition[stateKey] = Object.create(null)
})
}

sbp('sbp/selectors/register', {
'gi.periodicNotifications/init': function () {
runNotificationListRecursive(every1MinTimeout)
runNotificationListRecursive(every5MinTimeout)
runNotificationListRecursive(every15MinTimeout)
runNotificationListRecursive(every30MinTimeout)
runNotificationListRecursive().catch((e) => {
console.error('[gi.periodicNotifications/init] Error', e)
})
},
'gi.periodicNotifications/clearStatesAndStopTimers': function () {
const rootState = sbp('state/vuex/state')

Vue.set(rootState, 'periodicNotificationAlreadyFiredMap', {})
clearTimeoutObject(every1MinTimeout)
clearTimeoutObject(every5MinTimeout)
clearTimeoutObject(every15MinTimeout)
clearTimeoutObject(every30MinTimeout)
rootState.periodicNotificationAlreadyFiredMap = Object.create(null, {
'alreadyFired': { value: Object.create(null) },
'lastRun': { value: Object.create(null) }
})
clearTimeoutObject()
},
'gi.periodicNotifications/importNotifications': function (entries) {
const keySet = new Set()
for (const { type, notificationData } of entries) {
if (!type || !notificationData) throw new Error('A required field in a periodic notification entry is missing.')

const delay = delayToObjectMap[type]
if (!delay) {
throw new RangeError('Invalid delay')
}

validateNotificationData(notificationData)
if (keySet.has(notificationData.stateKey)) {
throw new Error('Duplicate periodic notification state key: ' + notificationData.stateKey)
}
keySet.add(notificationData.stateKey)

const notificationList = typeToObjectMap[type].notifications
notificationList.push(notificationData)
ephemeralNotificationState.partition[notificationData.stateKey] = Object.create(null)
ephemeralNotificationState.notifications.push({
...notificationData,
delay
})
}
}
})

0 comments on commit 2d21c74

Please sign in to comment.