✨ feat(notification): implement notification center with persistent storage and DND support

This commit is contained in:
sankalp committed 2025-08-02 19:25:33 +05:45
1 parent 2e7207c4ac
commit 30ca6de913
15 files changed
+1260 -472

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
import json
import os
import threading
from typing import Dict, List
from fabric import Signal
from fabric.notifications import Notification, Notifications, NotificationSerializedData
from loguru import logger
import config.data as data
PERSISTENT_DIR = f"/tmp/{data.APP_NAME}/notifications"
NOTIFICATION_CACHE_FILE = os.path.join(PERSISTENT_DIR, "notification_history.json")
def write_json_file(data: Dict, path: str):
try:
with open(path, "w") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
except Exception as e:
logger.warning(f"Failed to write json: {e}")
class CustomNotifications(Notifications):
"""A service to manage the notifications."""
@Signal
def clear_all(self, value: bool) -> None:
"""Signal emitted when notifications are emptied."""
# Implement as needed for your application
@Signal
def notification_count(self, value: int) -> None:
"""Signal emitted when a new notification is added."""
# Implement as needed for your application
@Signal
def dnd(self, value: bool) -> None:
"""Signal emitted when dnd is toggled."""
# Implement as needed for your application
@property
def count(self) -> int:
"""Return the count of notifications."""
return len(self.all_notifications)
@property
def dont_disturb(self) -> bool:
"""Return the pause status."""
return self._dont_disturb
@dont_disturb.setter
def dont_disturb(self, value: bool):
"""Set the pause status."""
self._dont_disturb = value
self.emit("dnd", value)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.all_notifications = []
self._count = 0 # Will be updated to highest ID when loading
self.deserialized_notifications = []
self._dont_disturb = False
self._lock = threading.Lock() # Add missing lock for thread safety
self._load_notifications()
def _load_notifications(self):
"""Read and validate notifications from the cache file."""
if not os.path.exists(NOTIFICATION_CACHE_FILE):
return
try:
with open(NOTIFICATION_CACHE_FILE, "r") as file:
original_data = json.load(file)
original_data.reverse()
valid_notifications = []
highest_id = self._count
for notification in original_data:
try:
self._deserialize_notification(notification)
valid_notifications.append(notification)
highest_id = max(highest_id, notification.get("id", 0))
except Exception as e:
msg = f"[Notification] Invalid: {str(e)[:50]}"
logger.exception(f"{msg}")
# Write only if the validated data differs from what was originally loaded
if valid_notifications != original_data:
write_json_file(valid_notifications, NOTIFICATION_CACHE_FILE)
logger.info("[Notification] Notifications written successfully.")
self.all_notifications = valid_notifications
self._count = highest_id
del valid_notifications
del original_data
except (json.JSONDecodeError, KeyError, ValueError, IndexError) as e:
logger.exception(f"[Notification] {e}")
def remove_notification(self, id: int):
"""Remove a notification by ID, ensuring thread safety."""
with self._lock:
item = next((p for p in self.all_notifications if p["id"] == id), None)
if item:
self.all_notifications.remove(item)
self._persist_and_emit()
if len(self.all_notifications) == 0:
self.emit("clear_all", True)
def cache_notification(self, widget_config, data: Notification, max_count: int):
"""Cache a notification, ensuring thread safety."""
with self._lock:
self._cleanup_invalid_notifications()
new_notification = self._create_serialized_notification(data)
self._enforce_per_app_limit(widget_config, new_notification, max_count)
self.all_notifications.append(new_notification)
self._enforce_global_limit(max_count)
self._persist_and_emit()
def _cleanup_invalid_notifications(self):
"""Remove any invalid notifications."""
valid_notifications = []
invalid_count = 0
for notification in self.all_notifications:
try:
self._deserialize_notification(notification)
valid_notifications.append(notification)
except Exception as e:
msg = f"[Notification] Removing invalid: {str(e)[:50]}"
logger.debug(msg)
invalid_id = notification.get("id", 0)
self.emit("notification-closed", invalid_id, "dismissed-by-limit")
invalid_count += 1
if invalid_count > 0:
self.all_notifications = valid_notifications
self._persist_and_emit()
del valid_notifications
logger.info(f"[Notification] Cleaned {invalid_count} invalid notifications")
def _create_serialized_notification(self, data: Notification) -> dict:
"""Generate a new notification with a unique ID."""
self._count += 1
serialized = data.serialize()
serialized.update(
{
"id": self._count,
"app_name": data.app_name,
}
)
return serialized
def _enforce_global_limit(self, max_count: int):
"""Remove oldest notifications if total count exceeds global limit."""
while len(self.all_notifications) > max_count:
oldest = self.all_notifications.pop(0)
self.emit("notification-closed", oldest["id"], "dismissed-by-limit")
def _enforce_per_app_limit(
self, widget_config, new_notification: dict, max_count: int
):
"""Ensure per-app limits are respected."""
app_name = new_notification["app_name"]
per_app_limits = widget_config.get("notification", {}).get("per_app_limits", {})
app_limit = per_app_limits.get(app_name, max_count)
app_notifications = [
n for n in self.all_notifications if n["app_name"] == app_name
]
if len(app_notifications) >= app_limit:
app_notifications.sort(key=lambda x: x["id"]) # Oldest first
to_remove = len(app_notifications) - app_limit + 1
for old in app_notifications[:to_remove]:
self.all_notifications.remove(old)
self.emit("notification-closed", old["id"], "dismissed-by-limit")
def _deserialize_notification(self, notification: NotificationSerializedData):
"""Deserialize a notification."""
return Notification.deserialize(notification)
def _persist_and_emit(self):
"""Persist notifications and emit relevant signals."""
write_json_file(self.all_notifications, NOTIFICATION_CACHE_FILE)
self.emit("notification_count", len(self.all_notifications))
def clear_all_notifications(self):
"""Empty the notifications."""
logger.info("[Notification] Clearing all notifications")
# Clear notifications but preserve the highest ID we've seen
highest_id = self._count
self.all_notifications.clear()
self._persist_and_emit()
logger.info("[Notification] Notifications written successfully.")
self.emit("clear_all", True)
# Restore the ID counter so new notifications get unique IDs
self._count = highest_id
def get_deserialized(self) -> List[Notification]:
"""Return the notifications."""
def deserialize_with_id(notification):
"""Helper to deserialize and return result with ID."""
try:
return (self._deserialize_notification(notification), None)
except Exception as e:
msg = f"[Notification] Deserialize failed: {str(e)[:50]}"
logger.exception(f"{msg}")
return (None, notification.get("id"))
# Process all notifications at once
results = [
deserialize_with_id(notification) for notification in self.all_notifications
]
# Split into successful and failed
deserialized = []
invalid_ids = []
for result, error_id in results:
if result is not None:
deserialized.append(result)
elif error_id is not None:
invalid_ids.append(error_id)
# Clean up invalid notifications
for invalid_id in invalid_ids:
self.remove_notification(invalid_id)
return deserialized
def get_deserialized_with_ids(self) -> List[tuple[Notification, int]]:
"""Return the notifications with their IDs as tuples."""
def deserialize_with_id(notification):
"""Helper to deserialize and return result with ID."""
try:
deserialized = self._deserialize_notification(notification)
notification_id = notification.get("id")
return (deserialized, notification_id, None)
except Exception as e:
msg = f"[Notification] Deserialize failed: {str(e)[:50]}"
logger.exception(f"{msg}")
return (None, notification.get("id"), notification.get("id"))
# Process all notifications at once
results = [
deserialize_with_id(notification) for notification in self.all_notifications
]
# Split into successful and failed
deserialized_with_ids = []
invalid_ids = []
for result, notification_id, error_id in results:
if result is not None:
deserialized_with_ids.append((result, notification_id))
elif error_id is not None:
invalid_ids.append(error_id)
# Clean up invalid notifications
for invalid_id in invalid_ids:
self.remove_notification(invalid_id)
return deserialized_with_ids
+20 -41
View File
@@ -4,7 +4,9 @@ from loguru import logger
from fabric.core.service import Property, Service, Signal
from fabric.notifications import Notification
from services.custom_notification import CustomNotifications
notification_service = CustomNotifications()
class ModusService(Service):
@Signal
@@ -22,12 +24,6 @@ class ModusService(Service):
@Signal
def dock_apps_changed(self, new_dock_apps: str) -> None: ...
@Signal
def clear_all_changed(self, value: bool) -> None: ...
@Signal
def notification_count_changed(self, value: int) -> None: ...
@Signal
def dont_disturb_changed(self, value: bool) -> None: ...
@@ -55,6 +51,9 @@ class ModusService(Service):
@Signal
def show_notificationcenter_changed(self, value: bool) -> None: ...
@Signal
def notification_count_changed(self, value: int) -> None: ...
@Property(str, flags="read-write")
def current_active_app_name(self) -> str:
return self._current_active_app_name
@@ -79,10 +78,6 @@ class ModusService(Service):
def dock_apps(self) -> str:
return self._dock_apps
@Property(str, flags="read-write")
def notification_count(self) -> int:
return self._notification_count
@Property(str, flags="read-write")
def dont_disturb(self) -> bool:
return self._dont_disturb
@@ -220,8 +215,6 @@ class ModusService(Service):
self._battery = ""
self._bluetooth = ""
self._dock_apps = ""
self._notifications = []
self._notification_count = len(self._notifications)
self._dont_disturb = False
self._current_active_app_name = ""
self._music = ""
@@ -233,43 +226,29 @@ class ModusService(Service):
self._dock_width = 0
self._dock_height = 0
self.notifications = []
def remove_notification(self, id: int):
item = next((p for p in self._notifications if p["id"] == id), None)
if item is None:
return
index = self._notifications.index(item)
self._notifications.pop(index)
self._notification_count -= 1
self.notification_count_changed(self._notification_count)
if self._notification_count == 0:
self.clear_all_changed(True)
notification_service.remove_notification(id)
self.notification_count_changed(self.notification_count)
def cache_notification(self, data: Notification):
existing_data = self._notifications
serialized_data = data.serialize()
serialized_data.update({"id": self._notification_count + 1})
existing_data.append(serialized_data)
self._notification_count += 1
self._notifications = existing_data
self.notification_count_changed(self._notification_count)
widget_config = {"notification": {"per_app_limits": {}}}
notification_service.cache_notification(widget_config, data, max_count=100)
self.notification_count_changed(self.notification_count)
def clear_all_notifications(self):
self._notifications = []
self._notification_count = 0
self.clear_all_changed(True)
self.notification_count_changed(self._notification_count)
notification_service.clear_all_notifications()
self.notification_count_changed(self.notification_count)
def get_deserialized(self) -> List[Notification]:
if len(self.notifications) <= 0:
self.notifications = [
Notification.deserialize(data) for data in self._notifications
]
return self.notifications
return notification_service.get_deserialized()
def get_deserialized_with_ids(self):
return notification_service.get_deserialized_with_ids()
@property
def notification_count(self) -> int:
return notification_service.count
global modus_service