CI / test (push) Successful in 10m6s
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.
296 lines
7.4 KiB
JavaScript
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
|