Skip to content

Commit e5a99c8

Browse files
authored
Merge pull request #9 from EpixZone/notifications
Notifications
2 parents e954cfb + f7fa534 commit e5a99c8

11 files changed

Lines changed: 934 additions & 91 deletions

File tree

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import re
2+
import os
3+
import time
4+
5+
from Config import config
6+
from Plugin import PluginManager
7+
from Debug import Debug
8+
from util import helper
9+
from util.Flag import flag
10+
11+
12+
plugin_dir = os.path.dirname(__file__)
13+
media_dir = plugin_dir + "/media"
14+
15+
# Track previous notification totals per user to detect increases for tray toast
16+
_last_totals = {}
17+
18+
19+
@PluginManager.registerTo("UiRequest")
20+
class UiRequestPlugin(object):
21+
def actionUiMedia(self, path):
22+
if path == "/uimedia/all.js" or path == "/uimedia/all.css":
23+
# First yield the original file and header
24+
body_generator = super(UiRequestPlugin, self).actionUiMedia(path)
25+
for part in body_generator:
26+
yield part
27+
28+
# Append notification plugin media
29+
ext = re.match(".*(js|css)$", path).group(1)
30+
plugin_media_file = "%s/all.%s" % (media_dir, ext)
31+
if config.debug:
32+
from Debug import DebugMedia
33+
DebugMedia.merge(plugin_media_file)
34+
if ext == "js":
35+
yield open(plugin_media_file).read().encode("utf8")
36+
else:
37+
for part in self.actionFile(plugin_media_file, send_header=False):
38+
yield part
39+
else:
40+
for part in super(UiRequestPlugin, self).actionUiMedia(path):
41+
yield part
42+
43+
44+
@PluginManager.registerTo("UiWebsocket")
45+
class UiWebsocketPlugin(object):
46+
# Subscribe to notification queries for a site
47+
def actionNotificationSubscribe(self, to, subscriptions):
48+
self.user.setNotificationSubscriptions(self.site.address, subscriptions)
49+
self.user.save()
50+
self.response(to, "ok")
51+
52+
# List current notification subscriptions for the current site
53+
def actionNotificationList(self, to):
54+
notifications = self.user.sites.get(self.site.address, {}).get("notifications", {})
55+
self.response(to, notifications)
56+
57+
# Mute/unmute notifications globally or per-site
58+
# muted: bool — the mute state to set
59+
# site_address: optional — if provided, mute only that site; otherwise mute all
60+
@flag.admin
61+
def actionNotificationMute(self, to, muted, site_address=None):
62+
muted = bool(muted)
63+
if site_address:
64+
# Per-site mute
65+
site_data = self.user.getSiteData(site_address)
66+
site_data["notification_muted"] = muted
67+
else:
68+
# Global mute
69+
self.user.settings["notification_muted"] = muted
70+
self.user.save()
71+
self.response(to, "ok")
72+
73+
# Get current mute settings
74+
@flag.admin
75+
def actionNotificationMuteStatus(self, to):
76+
global_muted = self.user.settings.get("notification_muted", False)
77+
78+
# Collect per-site mute states for sites that have notification subscriptions
79+
site_mutes = {}
80+
for address, site_data in list(self.user.sites.items()):
81+
if not site_data.get("notifications"):
82+
continue
83+
site_mutes[address] = site_data.get("notification_muted", False)
84+
85+
self.response(to, {
86+
"global_muted": global_muted,
87+
"site_mutes": site_mutes
88+
})
89+
90+
# Query notification counts across all subscribed sites
91+
@flag.admin
92+
def actionNotificationQuery(self, to):
93+
from Site import SiteManager
94+
95+
# Check global mute
96+
if self.user.settings.get("notification_muted", False):
97+
return self.response(to, {
98+
"results": [],
99+
"num": 0,
100+
"sites": 0,
101+
"taken": 0,
102+
"muted": True
103+
})
104+
105+
results = []
106+
total_s = time.time()
107+
num_sites = 0
108+
109+
for address, site_data in list(self.user.sites.items()):
110+
subscriptions = site_data.get("notifications")
111+
if not subscriptions:
112+
continue
113+
if type(subscriptions) is not dict:
114+
self.log.debug("Invalid notifications for site %s" % address)
115+
continue
116+
117+
# Check per-site mute
118+
if site_data.get("notification_muted", False):
119+
continue
120+
121+
site = SiteManager.site_manager.get(address)
122+
if not site or not site.storage.has_db:
123+
continue
124+
125+
num_sites += 1
126+
content_json = site.content_manager.contents.get("content.json", {})
127+
title = content_json.get("title", address)
128+
# Icons: site declares "notification_icons" dict in content.json
129+
# Maps notification name to icon path: {"new_mail": "img/notif-mail.png", ...}
130+
icons = content_json.get("notification_icons", {})
131+
if not isinstance(icons, dict):
132+
icons = {}
133+
134+
for name, query_set in subscriptions.items():
135+
s = time.time()
136+
try:
137+
query_raw, params = query_set
138+
if not re.match(r'^SELECT\s', query_raw, re.IGNORECASE):
139+
self.log.error("Notification query must start with SELECT: %s" % name)
140+
continue
141+
142+
query = query_raw
143+
if params:
144+
query_params = map(helper.sqlquote, params)
145+
query = query.replace(":params", ",".join(query_params))
146+
147+
# Replace {xid_directory} placeholder with user's directory for this site
148+
if "{xid_directory}" in query:
149+
xid_dir = self.user.getUserDirectory(address)
150+
if not xid_dir:
151+
continue
152+
query = query.replace("{xid_directory}", xid_dir)
153+
154+
# Replace {last_seen} with dismiss timestamp so queries can filter
155+
dismissed = site_data.get("notification_dismissed", {})
156+
last_seen = dismissed.get(name, 0)
157+
if "{last_seen}" in query:
158+
query = query.replace("{last_seen}", str(int(last_seen)))
159+
160+
res = site.storage.query(query)
161+
row = next(res, None)
162+
if row:
163+
row = dict(row)
164+
count = row.get("count", row.get("COUNT(*)", 0))
165+
else:
166+
count = 0
167+
168+
# Subtract "seen" baseline from site's private user settings
169+
# Sites save notification_seen.{name} via userSetSettings when visited
170+
site_settings = site_data.get("settings", {})
171+
if site_settings:
172+
seen = site_settings.get("notification_seen", {})
173+
baseline = seen.get(name, 0)
174+
if baseline and count:
175+
count = max(0, count - baseline)
176+
177+
result_entry = {
178+
"site": address,
179+
"title": title,
180+
"name": name,
181+
"count": count,
182+
"last_seen": last_seen,
183+
"taken": round(time.time() - s, 3)
184+
}
185+
# Look up per-notification icon from the site's icon map
186+
icon = icons.get(name)
187+
if icon:
188+
result_entry["icon"] = icon
189+
results.append(result_entry)
190+
except Exception as err:
191+
self.log.error("%s notification query %s error: %s" % (address, name, Debug.formatException(err)))
192+
results.append({
193+
"site": address,
194+
"title": title,
195+
"name": name,
196+
"count": 0,
197+
"error": str(err)
198+
})
199+
200+
time.sleep(0.001)
201+
202+
# Fire OS tray toast if notification count increased
203+
new_total = sum(r.get("count", 0) for r in results)
204+
user_key = getattr(self.user, "master_address", "default")
205+
prev_total = _last_totals.get(user_key, 0)
206+
_last_totals[user_key] = new_total
207+
208+
if new_total > prev_total:
209+
try:
210+
import main
211+
announce = getattr(main.actions, "announce", None)
212+
if announce:
213+
parts = []
214+
for r in results:
215+
if r.get("count", 0) > 0:
216+
parts.append("%s %s" % (r["count"], r.get("title", r.get("name", ""))))
217+
if parts:
218+
announce("\n".join(parts), title="")
219+
except Exception as err:
220+
self.log.error("Notification toast error: %s" % Debug.formatException(err))
221+
222+
return self.response(to, {
223+
"results": results,
224+
"num": len(results),
225+
"sites": num_sites,
226+
"taken": round(time.time() - total_s, 3)
227+
})
228+
229+
def _dismissNotification(self, site_address, name):
230+
site_data = self.user.getSiteData(site_address)
231+
if "notification_dismissed" not in site_data:
232+
site_data["notification_dismissed"] = {}
233+
site_data["notification_dismissed"][name] = int(time.time() * 1000)
234+
self.user.save()
235+
236+
# Dismiss (mark as seen) notifications for a site
237+
@flag.admin
238+
def actionNotificationDismiss(self, to, site_address, name):
239+
self._dismissNotification(site_address, name)
240+
self.response(to, "ok")
241+
242+
# Dismiss own site's notifications (callable from within the site iframe, no admin needed)
243+
def actionNotificationDismissSelf(self, to, name):
244+
self._dismissNotification(self.site.address, name)
245+
self.response(to, "ok")
246+
247+
248+
@PluginManager.registerTo("User")
249+
class UserPlugin(object):
250+
def setNotificationSubscriptions(self, address, subscriptions):
251+
site_data = self.getSiteData(address)
252+
site_data["notifications"] = subscriptions
253+
self.save()
254+
return site_data

plugins/Notification/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import NotificationPlugin
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
/* ---- Notification.css ---- */
3+
4+
5+
.fixbutton-badge {
6+
position: absolute;
7+
top: 20px;
8+
left: 50%;
9+
transform: translate(-50%, -50%);
10+
background-color: #e74c3c;
11+
color: #fff;
12+
border-radius: 2px;
13+
min-width: 14px;
14+
height: 14px;
15+
font-size: 9px;
16+
font-weight: bold;
17+
display: none;
18+
align-items: center;
19+
justify-content: center;
20+
padding: 0 2px;
21+
z-index: 1000;
22+
pointer-events: none;
23+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
24+
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
25+
box-sizing: border-box;
26+
animation: badge-pop 0.3s ease-out;
27+
}
28+
29+
@keyframes badge-pop {
30+
0% { transform: translate(-50%, -50%) scale(0); }
31+
70% { transform: translate(-50%, -50%) scale(1.15); }
32+
100% { transform: translate(-50%, -50%) scale(1); }
33+
}

0 commit comments

Comments
 (0)