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 ## Version 1.2.0 - 2025-12-17
Removes Umami analytics integration, eliminating user tracking functionality. Developers must handle analytics separately if needed. 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] [project]
name = "Snek" name = "Snek"
version = "1.2.0" version = "1.3.0"
readme = "README.md" readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" } #license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz" description = "Snek Chat Application by Molodetz"

View File

@ -8,6 +8,8 @@ from snek.system.service import BaseService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from snek.system.model import now from snek.system.model import now
PRESENCE_DEBOUNCE_SECONDS = 3.0
class SocketService(BaseService): class SocketService(BaseService):
@ -16,6 +18,8 @@ class SocketService(BaseService):
self.ws = ws self.ws = ws
self.is_connected = True self.is_connected = True
self.user = user 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): async def send_json(self, data):
if not self.is_connected: if not self.is_connected:
@ -30,7 +34,10 @@ class SocketService(BaseService):
if not self.is_connected: if not self.is_connected:
return True return True
await self.ws.close() try:
await self.ws.close()
except Exception:
pass
self.is_connected = False self.is_connected = False
return True return True
@ -41,6 +48,7 @@ class SocketService(BaseService):
self.users = {} self.users = {}
self.subscriptions = {} self.subscriptions = {}
self.last_update = str(datetime.now()) self.last_update = str(datetime.now())
self._departure_tasks = {}
async def user_availability_service(self): async def user_availability_service(self):
logger.info("User availability update service started.") logger.info("User availability update service started.")
@ -70,15 +78,34 @@ class SocketService(BaseService):
await asyncio.sleep(60) await asyncio.sleep(60)
async def add(self, ws, user_uid): 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) self.sockets.add(s)
s.user["last_ping"] = now() s.user["last_ping"] = now()
await self.app.services.user.save(s.user) await self.app.services.user.save(s.user)
logger.info(f"Added socket for user {s.user['username']}") 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): if not self.users.get(user_uid):
self.users[user_uid] = set() 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) 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): async def subscribe(self, ws, channel_uid, user_uid):
if channel_uid not in self.subscriptions: if channel_uid not in self.subscriptions:
self.subscriptions[channel_uid] = set() self.subscriptions[channel_uid] = set()
@ -110,5 +137,58 @@ class SocketService(BaseService):
async def delete(self, ws): async def delete(self, ws):
for s in [sock for sock in self.sockets if sock.ws == ws]: for s in [sock for sock in self.sockets if sock.ws == ws]:
await s.close() await s.close()
logger.info(f"Removed socket for user {s.user['username']}") user_uid = s.user_uid
self.sockets.remove(s) 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 { EventHandler } from "./event-handler.js";
import { Socket } from "./socket.js"; import { Socket } from "./socket.js";
import { Njet } from "./njet.js"; import { Njet } from "./njet.js";
import { PresenceNotification } from "./presence-notification.js";
export class RESTClient { export class RESTClient {
debug = false; debug = false;
@ -160,7 +161,8 @@ export class App extends EventHandler {
typeLock = null; typeLock = null;
typeListener = null; typeListener = null;
typeEventChannelUid = null; typeEventChannelUid = null;
_debug = false _debug = false;
presenceNotification = null;
async set_typing(channel_uid) { async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid; this.typeEventChannel_uid = channel_uid;
} }
@ -192,6 +194,7 @@ export class App extends EventHandler {
this.ws = new Socket(); this.ws = new Socket();
this.rpc = this.ws.client; this.rpc = this.ws.client;
this.audio = new NotificationAudio(500); this.audio = new NotificationAudio(500);
this.presenceNotification = new PresenceNotification(this.ws);
this.is_pinging = false; this.is_pinging = false;
this.ping_interval = setInterval(() => { this.ping_interval = setInterval(() => {
this.ping("active"); 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="/message-list.js" type="module"></script>
<script src="/chat-input.js" type="module"></script> <script src="/chat-input.js" type="module"></script>
<script src="/container.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> <script src="/dumb-term.js" type="module"></script>
<link rel="stylesheet" href="/sandbox.css"> <link rel="stylesheet" href="/sandbox.css">
<link rel="stylesheet" href="/user-list.css"> <link rel="stylesheet" href="/user-list.css">

View File

@ -627,40 +627,44 @@ class RPCView(BaseView):
ws = web.WebSocketResponse() ws = web.WebSocketResponse()
await ws.prepare(self.request) await ws.prepare(self.request)
if self.request.session.get("logged_in"): user_uid = self.request.session.get("uid") if self.request.session.get("logged_in") else None
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}},
)
rpc = RPCView.RPCApi(self, ws) try:
async for msg in ws: if user_uid:
if msg.type == web.WSMsgType.TEXT: await self.services.socket.add(ws, user_uid)
try: async for subscription in self.services.channel_member.find(
await rpc(msg.json()) user_uid=user_uid,
except Exception as ex: deleted_at=None,
print("XXXXXXXXXX Deleting socket", ex, flush=True) is_banned=False,
logger.exception(ex) ):
await self.services.socket.delete(ws) 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 break
elif msg.type == web.WSMsgType.ERROR: elif msg.type == web.WSMsgType.CLOSE:
pass break
elif msg.type == web.WSMsgType.CLOSE: finally:
pass await self.services.socket.delete(ws)
return ws return ws