Files
peardock/libs/notifications.js
T
snxraven 869ba6a932
CI / test (push) Successful in 10m6s
Expand Settings into categorized subtabs with full client prefs
Add Appearance, Behavior, Notifications, Tunnels, Templates,
Connections, Terminal, and About panels. Wire density, sidebar,
confirmations, refresh, badge mode, toasts, tunnel defaults,
template lists, peer reset, terminal themes, and read-only server
feature flags into peardock.settings.v1.
2026-07-10 23:45:11 -04:00

296 lines
7.4 KiB
JavaScript

/**
* Notification Manager
* Centralized notification system with persistence and tray support.
*
* Bell badge: only notifications with badge: true count (see deservesBadge).
* All notifications still appear in the notification center history.
*/
const STORAGE_KEY = 'peardock_notifications'
const MAX_NOTIFICATIONS = 100 // Limit stored notifications
/**
* Whether a notification should bump the bell badge.
* Default: danger + warning only. success/info are history-only unless
* options.badge is forced true.
* @param {string} type
* @param {{ badge?: boolean }} [options]
* @returns {boolean}
*/
/**
* Read badge mode from client settings (alerts | all | none).
*/
function getBadgeMode() {
try {
if (typeof document !== 'undefined' && document.body?.dataset?.badgeMode) {
return document.body.dataset.badgeMode
}
if (typeof localStorage !== 'undefined') {
const raw = JSON.parse(localStorage.getItem('peardock.settings.v1') || '{}')
if (raw.badgeMode) return raw.badgeMode
}
} catch {
// ignore
}
return 'alerts'
}
export function deservesBadge(type, options = {}) {
if (options.badge === true) return true
if (options.badge === false) return false
const mode = getBadgeMode()
if (mode === 'none') return false
if (mode === 'all') return true
const t = String(type || 'info').toLowerCase()
return t === 'danger' || t === 'warning' || t === 'error'
}
class NotificationManager {
constructor() {
this.notifications = []
this.listeners = []
this._storageLoaded = false
// Defer loading from storage to not block initialization
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => this.loadFromStorage(), { timeout: 1000 })
} else {
setTimeout(() => this.loadFromStorage(), 0)
}
}
/**
* Load notifications from localStorage
*/
loadFromStorage() {
if (this._storageLoaded) return
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored)
this.notifications = parsed.map((n) => {
const type = n.type || 'info'
// Backfill badge for older records (infer from type when missing)
const badge =
typeof n.badge === 'boolean' ? n.badge : deservesBadge(type, {})
return {
...n,
type,
badge,
timestamp: new Date(n.timestamp),
}
})
this.notifications = this.notifications.slice(-MAX_NOTIFICATIONS)
}
this._storageLoaded = true
this.notify()
} catch (err) {
console.error('[ERROR] Failed to load notifications from storage:', err)
this.notifications = []
this._storageLoaded = true
}
}
/**
* Save notifications to localStorage
*/
saveToStorage() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.notifications))
} catch (err) {
console.error('[ERROR] Failed to save notifications to storage:', err)
}
}
/**
* Subscribe to notification changes
* @param {Function} callback - (notifications, badgeCount) => void
*/
subscribe(callback) {
this.listeners.push(callback)
callback(this.notifications, this.getBadgeCount())
}
/**
* Unsubscribe from notification changes
* @param {Function} callback
*/
unsubscribe(callback) {
this.listeners = this.listeners.filter((listener) => listener !== callback)
}
/**
* Notify all subscribers
*/
notify() {
const badgeCount = this.getBadgeCount()
this.listeners.forEach((callback) => {
try {
callback(this.notifications, badgeCount)
} catch (err) {
console.error('[ERROR] Notification listener error:', err)
}
})
}
/**
* Add a new notification (always stored in the center).
* @param {string} type - success | danger | warning | info
* @param {string} message
* @param {{
* autoDismiss?: boolean,
* duration?: number,
* badge?: boolean,
* key?: string,
* }} [options]
* badge: force bell badge (true) or suppress (false); default by type
* @returns {string} Notification ID
*/
add(type, message, options = {}) {
const id = this.generateId()
const tone = String(type || 'info')
const badge = deservesBadge(tone, options)
const notification = {
id,
type: tone,
message,
timestamp: new Date(),
// History-only items are treated as already "seen" for badge purposes
read: !badge,
badge,
autoDismiss: options.autoDismiss !== false,
duration: options.duration || 5000,
key: options.key || null,
}
this.notifications.unshift(notification)
this.notifications = this.notifications.slice(0, MAX_NOTIFICATIONS)
this.saveToStorage()
this.notify()
if (notification.autoDismiss) {
setTimeout(() => {
this.remove(id)
}, notification.duration)
}
return id
}
/**
* Remove a notification
* @param {string} id
*/
remove(id) {
this.notifications = this.notifications.filter((n) => n.id !== id)
this.saveToStorage()
this.notify()
}
/**
* Mark notification as read (clears badge contribution if badge-worthy)
* @param {string} id
*/
markAsRead(id) {
const notification = this.notifications.find((n) => n.id === id)
if (notification && !notification.read) {
notification.read = true
this.saveToStorage()
this.notify()
}
}
/**
* Mark all notifications as read (clears bell badge)
*/
markAllAsRead() {
let changed = false
this.notifications.forEach((n) => {
if (!n.read) {
n.read = true
changed = true
}
})
if (changed) {
this.saveToStorage()
this.notify()
}
}
/**
* Clear all notifications
*/
clearAll() {
this.notifications = []
this.saveToStorage()
this.notify()
}
/**
* Count that drives the bell badge: unread + badge-worthy only.
* @returns {number}
*/
getBadgeCount() {
if (!this._storageLoaded) {
this.loadFromStorage()
}
return this.notifications.filter((n) => n.badge && !n.read).length
}
/**
* @deprecated Use getBadgeCount — kept for callers; same semantics (badge-only).
* @returns {number}
*/
getUnreadCount() {
return this.getBadgeCount()
}
/**
* Unread records in the center (includes history-only if ever marked unread).
* @returns {number}
*/
getCenterUnreadCount() {
if (!this._storageLoaded) {
this.loadFromStorage()
}
return this.notifications.filter((n) => !n.read).length
}
/**
* Get notifications with optional filtering
* @param {{ type?: string, read?: boolean, badge?: boolean }} [filters]
* @returns {Array}
*/
getNotifications(filters = {}) {
if (!this._storageLoaded) {
this.loadFromStorage()
}
let filtered = [...this.notifications]
if (filters.type && filters.type !== 'all') {
filtered = filtered.filter((n) => n.type === filters.type)
}
if (filters.read !== undefined) {
filtered = filtered.filter((n) => n.read === filters.read)
}
if (filters.badge !== undefined) {
filtered = filtered.filter((n) => Boolean(n.badge) === Boolean(filters.badge))
}
return filtered
}
/**
* @returns {string}
*/
generateId() {
return `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}
const notificationManager = new NotificationManager()
export default notificationManager