feat: add user presence notifications with debounced departures

This commit is contained in:
retoor 2025-12-17 21:54:07 +01:00
parent e798fd50a8
commit c23ce6085a
7 changed files with 390 additions and 40 deletions

View File

@ -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.

View File

@ -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"

View File

@ -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
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")

View File

@ -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");

View File

@ -0,0 +1,254 @@
// retoor <retoor@molodetz.nl>
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 };

View File

@ -25,6 +25,7 @@
<script src="/message-list.js" type="module"></script>
<script src="/chat-input.js" type="module"></script>
<script src="/container.js" type="module"></script>
<script src="/presence-notification.js" type="module"></script>
<script src="/dumb-term.js" type="module"></script>
<link rel="stylesheet" href="/sandbox.css">
<link rel="stylesheet" href="/user-list.css">

View File

@ -627,24 +627,27 @@ 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"))
user_uid = self.request.session.get("uid") if self.request.session.get("logged_in") else None
try:
if user_uid:
await self.services.socket.add(ws, user_uid)
async for subscription in self.services.channel_member.find(
user_uid=self.request.session.get("uid"),
user_uid=user_uid,
deleted_at=None,
is_banned=False,
):
await self.services.socket.subscribe(
ws, subscription["channel_uid"], self.request.session.get("uid")
ws, subscription["channel_uid"], user_uid
)
if not scheduled and self.request.app.uptime_seconds < 5:
await schedule(
self.request.session.get("uid"),
user_uid,
0,
{"event": "refresh", "data": {"message": "Finishing deployment"}},
)
await schedule(
self.request.session.get("uid"),
user_uid,
15,
{"event": "deployed", "data": {"uptime": self.request.app.uptime}},
)
@ -655,12 +658,13 @@ class RPCView(BaseView):
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)
break
elif msg.type == web.WSMsgType.ERROR:
pass
break
elif msg.type == web.WSMsgType.CLOSE:
pass
break
finally:
await self.services.socket.delete(ws)
return ws