feat: add user presence notifications with debounced departures
This commit is contained in:
parent
e798fd50a8
commit
c23ce6085a
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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");
|
||||
|
||||
254
src/snek/static/presence-notification.js
Normal file
254
src/snek/static/presence-notification.js
Normal 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 };
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user