From c23ce6085af2f692b0e5ef051c6753611b43ca08 Mon Sep 17 00:00:00 2001 From: retoor Date: Wed, 17 Dec 2025 21:54:07 +0100 Subject: [PATCH] feat: add user presence notifications with debounced departures --- CHANGELOG.md | 8 + pyproject.toml | 2 +- src/snek/service/socket.py | 88 +++++++- src/snek/static/app.js | 5 +- src/snek/static/presence-notification.js | 254 +++++++++++++++++++++++ src/snek/templates/app.html | 1 + src/snek/view/rpc.py | 72 ++++--- 7 files changed, 390 insertions(+), 40 deletions(-) create mode 100644 src/snek/static/presence-notification.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac3a91..da162ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ + +## Version 1.3.0 - 2025-12-17 + +Users now receive notifications when other users join or depart the application. Departure notifications are debounced to reduce the frequency of rapid successive alerts. + +**Changes:** 5 files, 418 lines +**Languages:** HTML (1 lines), JavaScript (259 lines), Python (158 lines) + ## Version 1.2.0 - 2025-12-17 Removes Umami analytics integration, eliminating user tracking functionality. Developers must handle analytics separately if needed. diff --git a/pyproject.toml b/pyproject.toml index 48ee9f1..ffc22f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "Snek" -version = "1.2.0" +version = "1.3.0" readme = "README.md" #license = { file = "LICENSE", content-type="text/markdown" } description = "Snek Chat Application by Molodetz" diff --git a/src/snek/service/socket.py b/src/snek/service/socket.py index 1b1b125..a156ca2 100644 --- a/src/snek/service/socket.py +++ b/src/snek/service/socket.py @@ -8,6 +8,8 @@ from snek.system.service import BaseService logger = logging.getLogger(__name__) from snek.system.model import now +PRESENCE_DEBOUNCE_SECONDS = 3.0 + class SocketService(BaseService): @@ -16,6 +18,8 @@ class SocketService(BaseService): self.ws = ws self.is_connected = True self.user = user + self.user_uid = user["uid"] if user else None + self.user_color = user["color"] if user else None async def send_json(self, data): if not self.is_connected: @@ -30,7 +34,10 @@ class SocketService(BaseService): if not self.is_connected: return True - await self.ws.close() + try: + await self.ws.close() + except Exception: + pass self.is_connected = False return True @@ -41,6 +48,7 @@ class SocketService(BaseService): self.users = {} self.subscriptions = {} self.last_update = str(datetime.now()) + self._departure_tasks = {} async def user_availability_service(self): logger.info("User availability update service started.") @@ -70,15 +78,34 @@ class SocketService(BaseService): await asyncio.sleep(60) async def add(self, ws, user_uid): - s = self.Socket(ws, await self.app.services.user.get(uid=user_uid)) + if not user_uid: + return None + user = await self.app.services.user.get(uid=user_uid) + if not user: + return None + s = self.Socket(ws, user) self.sockets.add(s) s.user["last_ping"] = now() await self.app.services.user.save(s.user) logger.info(f"Added socket for user {s.user['username']}") + + is_first_connection = False + if user_uid in self._departure_tasks: + self._departure_tasks[user_uid].cancel() + del self._departure_tasks[user_uid] + if not self.users.get(user_uid): self.users[user_uid] = set() + is_first_connection = True + elif len(self.users[user_uid]) == 0: + is_first_connection = True self.users[user_uid].add(s) + if is_first_connection: + await self._broadcast_presence("arrived", user_uid, s.user.get("nick") or s.user.get("username"), s.user_color) + + return s + async def subscribe(self, ws, channel_uid, user_uid): if channel_uid not in self.subscriptions: self.subscriptions[channel_uid] = set() @@ -110,5 +137,58 @@ class SocketService(BaseService): async def delete(self, ws): for s in [sock for sock in self.sockets if sock.ws == ws]: await s.close() - logger.info(f"Removed socket for user {s.user['username']}") - self.sockets.remove(s) + user_uid = s.user_uid + user_nick = s.user.get("nick") or s.user.get("username") if s.user else None + user_color = s.user_color + logger.info(f"Removed socket for user {s.user['username'] if s.user else 'unknown'}") + self.sockets.discard(s) + + if user_uid: + if user_uid in self.users: + self.users[user_uid].discard(s) + if len(self.users[user_uid]) == 0: + await self._schedule_departure(user_uid, user_nick, user_color) + + async def _schedule_departure(self, user_uid, user_nick, user_color): + if user_uid in self._departure_tasks: + return + + async def delayed_departure(): + try: + await asyncio.sleep(PRESENCE_DEBOUNCE_SECONDS) + if user_uid in self.users and len(self.users[user_uid]) == 0: + await self._broadcast_presence("departed", user_uid, user_nick, user_color) + if user_uid in self._departure_tasks: + del self._departure_tasks[user_uid] + except asyncio.CancelledError: + pass + except Exception as ex: + logger.warning(f"Error in departure broadcast: {ex}") + + self._departure_tasks[user_uid] = asyncio.create_task(delayed_departure()) + + async def _broadcast_presence(self, event_type, user_uid, user_nick, user_color): + if not user_uid or not user_nick: + return + message = { + "event": "user_presence", + "data": { + "type": event_type, + "user_uid": user_uid, + "user_nick": user_nick, + "user_color": user_color, + "timestamp": datetime.now().isoformat() + } + } + sent_count = 0 + for s in list(self.sockets): + if not s.is_connected: + continue + if s.user_uid == user_uid: + continue + try: + if await s.send_json(message): + sent_count += 1 + except Exception: + pass + logger.info(f"Broadcast presence '{event_type}' for {user_nick} to {sent_count} users") diff --git a/src/snek/static/app.js b/src/snek/static/app.js index cb3d0fa..7126506 100644 --- a/src/snek/static/app.js +++ b/src/snek/static/app.js @@ -11,6 +11,7 @@ import { Schedule } from "./schedule.js"; import { EventHandler } from "./event-handler.js"; import { Socket } from "./socket.js"; import { Njet } from "./njet.js"; +import { PresenceNotification } from "./presence-notification.js"; export class RESTClient { debug = false; @@ -160,7 +161,8 @@ export class App extends EventHandler { typeLock = null; typeListener = null; typeEventChannelUid = null; - _debug = false + _debug = false; + presenceNotification = null; async set_typing(channel_uid) { this.typeEventChannel_uid = channel_uid; } @@ -192,6 +194,7 @@ export class App extends EventHandler { this.ws = new Socket(); this.rpc = this.ws.client; this.audio = new NotificationAudio(500); + this.presenceNotification = new PresenceNotification(this.ws); this.is_pinging = false; this.ping_interval = setInterval(() => { this.ping("active"); diff --git a/src/snek/static/presence-notification.js b/src/snek/static/presence-notification.js new file mode 100644 index 0000000..a5d0054 --- /dev/null +++ b/src/snek/static/presence-notification.js @@ -0,0 +1,254 @@ +// retoor + +import { EventHandler } from "./event-handler.js"; + +class PresenceNotification extends EventHandler { + constructor(socket) { + super(); + this._socket = socket; + this._container = null; + this._maxNotifications = 4; + this._displayDuration = 3500; + this._fadeDuration = 500; + this._queue = []; + this._activeCount = 0; + this._initialized = false; + + this._init(); + } + + _init() { + if (this._initialized) { + return; + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => this._setup()); + } else { + this._setup(); + } + } + + _setup() { + if (this._initialized) { + return; + } + this._initialized = true; + this._createContainer(); + this._bindEvents(); + } + + _createContainer() { + if (this._container) { + return; + } + this._container = document.createElement("div"); + this._container.className = "presence-notification-container"; + this._container.setAttribute("aria-live", "polite"); + this._container.setAttribute("aria-atomic", "false"); + + const style = document.createElement("style"); + style.textContent = ` + .presence-notification-container { + position: fixed; + top: 60px; + right: 20px; + z-index: 10000; + pointer-events: none; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 280px; + } + + .presence-toast { + padding: 10px 16px; + border-radius: 6px; + font-size: 13px; + font-family: inherit; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: translateX(100%); + transition: opacity 0.3s ease, transform 0.3s ease; + pointer-events: auto; + display: flex; + align-items: center; + gap: 8px; + } + + .presence-toast.visible { + opacity: 1; + transform: translateX(0); + } + + .presence-toast.fade-out { + opacity: 0; + transform: translateX(100%); + } + + .presence-toast.arrived { + background: rgba(46, 125, 50, 0.95); + color: #fff; + border-left: 3px solid #81c784; + } + + .presence-toast.departed { + background: rgba(66, 66, 66, 0.95); + color: #bbb; + border-left: 3px solid #888; + } + + .presence-toast-icon { + font-size: 11px; + flex-shrink: 0; + } + + .presence-toast-text { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .presence-toast-nick { + font-weight: 600; + } + `; + + document.head.appendChild(style); + document.body.appendChild(this._container); + } + + _bindEvents() { + if (!this._socket) { + return; + } + + this._socket.addEventListener("user_presence", (data) => { + this._handlePresenceEvent(data); + }); + } + + _handlePresenceEvent(data) { + if (!data || !data.type || !data.user_nick) { + return; + } + + const type = data.type; + const nick = data.user_nick; + const color = data.user_color; + + if (type !== "arrived" && type !== "departed") { + return; + } + + this._queueNotification(type, nick, color); + } + + _queueNotification(type, nick, color) { + if (this._activeCount >= this._maxNotifications) { + this._queue.push({ type, nick, color }); + if (this._queue.length > this._maxNotifications * 2) { + this._queue.shift(); + } + return; + } + + this._showNotification(type, nick, color); + } + + _showNotification(type, nick, color) { + if (!this._container) { + return; + } + + this._activeCount++; + + const toast = document.createElement("div"); + toast.className = `presence-toast ${type}`; + toast.setAttribute("role", "status"); + + const icon = document.createElement("span"); + icon.className = "presence-toast-icon"; + icon.textContent = type === "arrived" ? "●" : "○"; + if (color) { + icon.style.color = color; + } + + const text = document.createElement("span"); + text.className = "presence-toast-text"; + + const nickSpan = document.createElement("span"); + nickSpan.className = "presence-toast-nick"; + nickSpan.textContent = nick; + if (color) { + nickSpan.style.color = color; + } + + const action = document.createTextNode(type === "arrived" ? " arrived" : " departed"); + + text.appendChild(nickSpan); + text.appendChild(action); + + toast.appendChild(icon); + toast.appendChild(text); + + this._container.appendChild(toast); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + toast.classList.add("visible"); + }); + }); + + setTimeout(() => { + this._removeNotification(toast); + }, this._displayDuration); + } + + _removeNotification(toast) { + if (!toast || !toast.parentNode) { + this._activeCount = Math.max(0, this._activeCount - 1); + this._processQueue(); + return; + } + + toast.classList.remove("visible"); + toast.classList.add("fade-out"); + + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + this._activeCount = Math.max(0, this._activeCount - 1); + this._processQueue(); + }, this._fadeDuration); + } + + _processQueue() { + if (this._queue.length === 0) { + return; + } + + if (this._activeCount >= this._maxNotifications) { + return; + } + + const next = this._queue.shift(); + if (next) { + this._showNotification(next.type, next.nick, next.color); + } + } + + destroy() { + if (this._container && this._container.parentNode) { + this._container.parentNode.removeChild(this._container); + } + this._container = null; + this._queue = []; + this._activeCount = 0; + this._initialized = false; + } +} + +export { PresenceNotification }; diff --git a/src/snek/templates/app.html b/src/snek/templates/app.html index 2a04232..d7cc53f 100644 --- a/src/snek/templates/app.html +++ b/src/snek/templates/app.html @@ -25,6 +25,7 @@ + diff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py index 67892c5..fe6bcba 100644 --- a/src/snek/view/rpc.py +++ b/src/snek/view/rpc.py @@ -627,40 +627,44 @@ class RPCView(BaseView): ws = web.WebSocketResponse() await ws.prepare(self.request) - if self.request.session.get("logged_in"): - await self.services.socket.add(ws, self.request.session.get("uid")) - async for subscription in self.services.channel_member.find( - user_uid=self.request.session.get("uid"), - deleted_at=None, - is_banned=False, - ): - await self.services.socket.subscribe( - ws, subscription["channel_uid"], self.request.session.get("uid") - ) - if not scheduled and self.request.app.uptime_seconds < 5: - await schedule( - self.request.session.get("uid"), - 0, - {"event": "refresh", "data": {"message": "Finishing deployment"}}, - ) - await schedule( - self.request.session.get("uid"), - 15, - {"event": "deployed", "data": {"uptime": self.request.app.uptime}}, - ) + user_uid = self.request.session.get("uid") if self.request.session.get("logged_in") else None - rpc = RPCView.RPCApi(self, ws) - async for msg in ws: - if msg.type == web.WSMsgType.TEXT: - try: - await rpc(msg.json()) - except Exception as ex: - print("XXXXXXXXXX Deleting socket", ex, flush=True) - logger.exception(ex) - await self.services.socket.delete(ws) + try: + if user_uid: + await self.services.socket.add(ws, user_uid) + async for subscription in self.services.channel_member.find( + user_uid=user_uid, + deleted_at=None, + is_banned=False, + ): + await self.services.socket.subscribe( + ws, subscription["channel_uid"], user_uid + ) + if not scheduled and self.request.app.uptime_seconds < 5: + await schedule( + user_uid, + 0, + {"event": "refresh", "data": {"message": "Finishing deployment"}}, + ) + await schedule( + user_uid, + 15, + {"event": "deployed", "data": {"uptime": self.request.app.uptime}}, + ) + + rpc = RPCView.RPCApi(self, ws) + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + try: + await rpc(msg.json()) + except Exception as ex: + logger.exception(ex) + break + elif msg.type == web.WSMsgType.ERROR: break - elif msg.type == web.WSMsgType.ERROR: - pass - elif msg.type == web.WSMsgType.CLOSE: - pass + elif msg.type == web.WSMsgType.CLOSE: + break + finally: + await self.services.socket.delete(ws) + return ws