feat: add online presence tracking with last_seen column and configurable timeout

Add `last_seen` column to users table with index, implement `set_last_seen` and `get_online_users` database functions, expose presence config env vars (`PRESENCE_TIMEOUT_SECONDS`, `PRESENCE_ONLINE_LIMIT`, `PRESENCE_ONLINE_MARGIN_SECONDS`), include `last_seen` in follow list responses, and update profile docs to mention online indicator.
This commit is contained in:
2026-07-04 22:08:20 +00:00
parent 7002b23eeb
commit 0f872336b1
41 changed files with 935 additions and 94 deletions
+7
View File
@@ -43,6 +43,13 @@ SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
PRESENCE_TIMEOUT_SECONDS = int(environ.get("DEVPLACE_PRESENCE_TIMEOUT_SECONDS", "60"))
PRESENCE_WRITE_SECONDS = max(1, PRESENCE_TIMEOUT_SECONDS // 2)
PRESENCE_ONLINE_LIMIT = int(environ.get("DEVPLACE_PRESENCE_ONLINE_LIMIT", "30"))
PRESENCE_ONLINE_MARGIN_SECONDS = int(
environ.get("DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS", "20")
)
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
+3 -1
View File
@@ -3,7 +3,7 @@
from .core import dataset, logging, Path, or_, defaultdict, datetime, timedelta, timezone, TTLCache, DATABASE_URL, DEFAULT_CORRECTION_PROMPT, DEFAULT_MODIFIER_PROMPT, INTERNAL_GATEWAY_URL, ensure_data_dirs, logger, db
from .core import refresh_snapshot, _local_cache_versions, _cache_version_cache, _cache_state_ready, _ensure_cache_state, get_cache_version, bump_cache_version, sync_local_cache, _index, _drop_index, _uid_index, get_table, _in_clause, _now_iso
from .settings import _settings_cache, get_setting, get_int_setting, set_setting, clear_settings_cache, internal_gateway_key
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, get_primary_admin_uid, search_users_by_username
from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, get_admin_uids, set_user_timezone, set_last_seen, get_online_users, get_primary_admin_uid, search_users_by_username
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, build_pagination
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
@@ -66,6 +66,8 @@ __all__ = [
"invalidate_admins_cache",
"get_admin_uids",
"set_user_timezone",
"set_last_seen",
"get_online_users",
"get_primary_admin_uid",
"search_users_by_username",
"_relations_cache",
+1
View File
@@ -45,6 +45,7 @@ def get_follow_list(
"uid": person["uid"],
"username": person["username"],
"bio": (person.get("bio") or "")[:140],
"last_seen": person.get("last_seen"),
"followed_at": row.get("created_at"),
}
)
+3
View File
@@ -36,6 +36,7 @@ def init_db():
_index(db, "users", "idx_users_email", ["email"])
_index(db, "users", "idx_users_api_key", ["api_key"])
_index(db, "users", "idx_users_role", ["role", "created_at"])
_index(db, "users", "idx_users_last_seen", ["last_seen"])
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
_index(db, "posts", "idx_posts_created_at", ["created_at"])
_index(db, "posts", "idx_posts_topic", ["topic"])
@@ -1117,6 +1118,8 @@ def backfill_api_keys() -> int:
users.create_column_by_example("timezone", "")
if not users.has_column("avatar_seed"):
users.create_column_by_example("avatar_seed", "")
if not users.has_column("last_seen"):
users.create_column_by_example("last_seen", "")
with db:
db.query(
"UPDATE users SET ai_modifier_enabled = 1 WHERE ai_modifier_enabled IS NULL"
+24
View File
@@ -47,6 +47,30 @@ def set_user_timezone(user_uid: str, tz_name: str) -> None:
users.update({"uid": user_uid, "timezone": tz_name}, ["uid"])
def set_last_seen(user_uid: str, iso: str) -> None:
if "users" not in db.tables or not user_uid or not iso:
return
users = db["users"]
if not users.has_column("last_seen"):
users.create_column_by_example("last_seen", "")
users.update({"uid": user_uid, "last_seen": iso}, ["uid"])
def get_online_users(cutoff_iso: str, limit: int = 30) -> list:
if "users" not in db.tables:
return []
users = db["users"]
if "last_seen" not in users.columns:
return []
return list(
users.find(
last_seen={">=": cutoff_iso},
order_by=["username"],
_limit=limit,
)
)
def get_primary_admin_uid():
sync_local_cache("admins", _admins_cache)
cached = _admins_cache.get("primary")
+1 -1
View File
@@ -41,7 +41,7 @@ four ways to sign requests.
method="GET",
path="/profile/{username}",
title="View a profile",
summary="Render a user profile. Returns an HTML page.",
summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.",
auth="public",
interactive=True,
params=[
+13
View File
@@ -100,6 +100,8 @@ from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.pubsub import PubSubService
from devplacepy.services.notification_relay import NotificationRelayService
from devplacepy.services.live_view_relay import LiveViewRelayService
from devplacepy.services.presence_relay import PresenceRelayService
from devplacepy.services import presence
from devplacepy.services.correction import PENDING_SCOPE_KEY
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
from devplacepy.services.gitea.service import IssueTrackerService
@@ -246,6 +248,7 @@ async def lifespan(app: FastAPI):
service_manager.register(PubSubService())
service_manager.register(NotificationRelayService())
service_manager.register(LiveViewRelayService())
service_manager.register(PresenceRelayService())
service_manager.register(DeepsearchService())
service_manager.register(IssueCreateService())
service_manager.register(PlanningReportService())
@@ -554,6 +557,16 @@ async def maintenance_middleware(request: Request, call_next):
)
@app.middleware("http")
async def track_presence(request: Request, call_next):
path = request.url.path
if not path.startswith(("/static", "/avatar")):
user = get_current_user(request)
if user:
presence.touch(user["uid"])
return await call_next(request)
@app.middleware("http")
async def response_timing(request: Request, call_next):
start = time.perf_counter()
+6
View File
@@ -117,6 +117,12 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "presence",
"title": "Online presence",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "ai-correction",
"title": "AI content correction",
+3
View File
@@ -19,6 +19,7 @@ from devplacepy.database import (
)
from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.services import presence
from devplacepy.utils import get_current_user
from devplacepy.seo import list_page_seo, next_page_url
from devplacepy.responses import respond
@@ -90,6 +91,7 @@ async def feed_page(
stats = get_site_stats()
top_authors = get_top_authors(5)
daily_topic = get_daily_topic()
online_users = presence.online_users()
post_uids_list = [item["post"]["uid"] for item in posts]
attachments_map = get_attachments_batch("post", post_uids_list)
@@ -134,6 +136,7 @@ async def feed_page(
"total_gists": stats["total_gists"],
"top_authors": top_authors,
"daily_topic": daily_topic,
"online_users": online_users,
"next_cursor": next_cursor,
},
model=FeedOut,
+5 -34
View File
@@ -26,6 +26,7 @@ from devplacepy.utils import (
from devplacepy.seo import base_seo_context
from devplacepy.responses import respond, action_result
from devplacepy.schemas import MessagesOut
from devplacepy.services import presence
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import PENDING_SCOPE_KEY
from devplacepy.dependencies import json_or_form
@@ -154,8 +155,8 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
user["uid"], f"/messages?with_uid={with_uid}"
)
current_conversation = with_uid
other_online = message_hub.is_online(with_uid)
other_last_seen = message_hub.last_seen(with_uid)
other_online = presence.is_online(other_user)
other_last_seen = other_user.get("last_seen") if other_user else None
audit.record(
request,
"message.read_on_view",
@@ -261,18 +262,6 @@ def _resolve_ws_user(websocket: WebSocket):
return _user_from_api_key(key)
return None
async def _announce_presence(user_uid: str, online: bool) -> None:
other_uids = {c["other_user"]["uid"] for c in get_conversations(user_uid) if c["other_user"]}
if not other_uids:
return
frame = {
"type": "presence",
"user_uid": user_uid,
"online": online,
"last_seen": None if online else message_hub.last_seen(user_uid),
}
await message_hub.send_to_users(list(other_uids), frame)
@router.websocket("/ws")
async def messages_ws(websocket: WebSocket):
await websocket.accept()
@@ -282,12 +271,10 @@ async def messages_ws(websocket: WebSocket):
return
user_uid = user["uid"]
was_offline = message_hub.register(user_uid, websocket)
message_hub.register(user_uid, websocket)
message_relay.start()
try:
await websocket.send_json({"type": "ready", "user_uid": user_uid})
if was_offline:
await _announce_presence(user_uid, True)
while True:
data = await websocket.receive_json()
kind = data.get("type")
@@ -338,25 +325,9 @@ async def messages_ws(websocket: WebSocket):
await message_hub.send_to_user(
with_uid, {"type": "read", "by_uid": user_uid}
)
elif kind == "presence":
with_uid = str(data.get("with_uid", "")).strip()
if with_uid:
await websocket.send_json(
{
"type": "presence",
"user_uid": with_uid,
"online": message_hub.is_online(with_uid),
"last_seen": message_hub.last_seen(with_uid),
}
)
except WebSocketDisconnect:
pass
except Exception: # noqa: BLE001
logger.exception("messages websocket loop failed for %s", user_uid)
finally:
went_offline = message_hub.unregister(user_uid, websocket)
if went_offline:
try:
await _announce_presence(user_uid, False)
except Exception: # noqa: BLE001
logger.debug("presence offline announce failed for %s", user_uid)
message_hub.unregister(user_uid, websocket)
+2
View File
@@ -49,6 +49,7 @@ from devplacepy.seo import (
website_schema,
profile_page_schema,
)
from devplacepy.services import presence
from devplacepy.services.audit import record as audit
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
@@ -369,6 +370,7 @@ async def profile_page(
"is_blocked": is_blocked,
"is_muted": is_muted,
"is_owner": is_owner,
"profile_online": presence.is_online(profile_user),
"viewer_is_admin": viewer_is_admin,
"media": media,
"media_pagination": media_pagination,
+1
View File
@@ -19,6 +19,7 @@ class UserOut(_Out):
xp: Optional[int] = None
stars: Optional[int] = None
created_at: Optional[str] = None
last_seen: Optional[str] = None
class AttachmentOut(_Out):
+1
View File
@@ -113,6 +113,7 @@ class FeedOut(_Out):
total_gists: Optional[int] = None
top_authors: list[UserOut] = []
daily_topic: Optional[Any] = None
online_users: list[UserOut] = []
class PostDetailOut(_Out):
+1
View File
@@ -40,6 +40,7 @@ class ProfileOut(_Out):
is_blocked: bool = False
is_muted: bool = False
is_owner: bool = False
profile_online: bool = False
can_view_api_key: bool = False
api_key: Optional[str] = None
ai_correction_enabled: bool = False
+1 -11
View File
@@ -2,8 +2,7 @@
import logging
from collections import OrderedDict
from datetime import datetime, timezone
from typing import Any, Optional
from typing import Any
from fastapi import WebSocket
@@ -15,14 +14,12 @@ DELIVERED_CAP = 4000
class ConnectionManager:
def __init__(self) -> None:
self._connections: dict[str, set[WebSocket]] = {}
self._last_seen: dict[str, str] = {}
self._delivered: "OrderedDict[str, bool]" = OrderedDict()
def register(self, user_uid: str, websocket: WebSocket) -> bool:
sockets = self._connections.setdefault(user_uid, set())
was_offline = len(sockets) == 0
sockets.add(websocket)
self._last_seen.pop(user_uid, None)
logger.info(
"messaging socket registered for %s (sockets=%d)", user_uid, len(sockets)
)
@@ -35,7 +32,6 @@ class ConnectionManager:
sockets.discard(websocket)
if not sockets:
self._connections.pop(user_uid, None)
self._last_seen[user_uid] = datetime.now(timezone.utc).isoformat()
logger.info("messaging socket removed for %s (now offline)", user_uid)
return True
logger.debug(
@@ -43,9 +39,6 @@ class ConnectionManager:
)
return False
def is_online(self, user_uid: str) -> bool:
return bool(self._connections.get(user_uid))
def has_connections(self) -> bool:
return bool(self._connections)
@@ -61,9 +54,6 @@ class ConnectionManager:
def was_delivered(self, message_uid: str) -> bool:
return message_uid in self._delivered
def last_seen(self, user_uid: str) -> Optional[str]:
return self._last_seen.get(user_uid)
def sockets_for(self, user_uid: str) -> list[WebSocket]:
return list(self._connections.get(user_uid, ()))
+77
View File
@@ -0,0 +1,77 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
from devplacepy.config import (
PRESENCE_ONLINE_LIMIT,
PRESENCE_ONLINE_MARGIN_SECONDS,
PRESENCE_TIMEOUT_SECONDS,
PRESENCE_WRITE_SECONDS,
)
from devplacepy.database import get_online_users, set_last_seen
_last_write: dict[str, float] = {}
def touch(user_uid: str) -> None:
if not user_uid:
return
now = time.monotonic()
if now - _last_write.get(user_uid, 0.0) < PRESENCE_WRITE_SECONDS:
return
_last_write[user_uid] = now
set_last_seen(user_uid, datetime.now(timezone.utc).isoformat())
def seconds_since(last_seen: Optional[str]) -> Optional[float]:
if not last_seen:
return None
try:
seen = datetime.fromisoformat(last_seen)
except (ValueError, TypeError):
return None
if seen.tzinfo is None:
seen = seen.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - seen).total_seconds()
def is_online(user: Optional[dict]) -> bool:
if not user:
return False
elapsed = seconds_since(user.get("last_seen"))
return elapsed is not None and elapsed < PRESENCE_TIMEOUT_SECONDS
def stays_online(elapsed: Optional[float], was_online: bool) -> bool:
if elapsed is None:
return False
grace = PRESENCE_TIMEOUT_SECONDS + PRESENCE_ONLINE_MARGIN_SECONDS
return elapsed < (grace if was_online else PRESENCE_TIMEOUT_SECONDS)
def _cutoff_iso(seconds: int) -> str:
return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()
def online_cutoff_iso() -> str:
return _cutoff_iso(PRESENCE_TIMEOUT_SECONDS)
def sort_by_username(rows: list) -> list:
return sorted(rows, key=lambda row: (row.get("username") or "").lower())
def online_users(limit: int = PRESENCE_ONLINE_LIMIT) -> list:
return sort_by_username(get_online_users(online_cutoff_iso(), limit))
def online_candidates(limit: int = PRESENCE_ONLINE_LIMIT) -> list:
return sort_by_username(
get_online_users(
_cutoff_iso(PRESENCE_TIMEOUT_SECONDS + PRESENCE_ONLINE_MARGIN_SECONDS), limit
)
)
+118
View File
@@ -0,0 +1,118 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import re
from typing import Optional
from devplacepy.config import PRESENCE_ONLINE_LIMIT
from devplacepy.database import db, get_users_by_uids
from devplacepy.services import presence
from devplacepy.services.base import BaseService
from devplacepy.services.pubsub import publish as pubsub_publish
from devplacepy.services.pubsub.hub import pubsub
ROSTER_TOPIC = "public.presence.roster"
TOPIC_PATTERN = re.compile(r"^public\.presence\.(?P<uid>[A-Za-z0-9_-]{1,128})$")
class PresenceRelayService(BaseService):
title = "Presence relay"
description = (
"Single source of truth for LIVE online status. Each tick it recomputes one online "
"set with hysteresis - a user is online at the timeout window but only drops after an "
"extra grace margin - and drives BOTH the per-user avatar dots (public.presence.{uid}) "
"and the shared feed roster (public.presence.roster) from that one set, so they never "
"disagree. It publishes ONLY on change (a real online<->offline transition or a first-seen "
"subscriber), reads due users in one batched query per tick, and does nothing when idle. "
"Runs on the service lock owner where every subscriber converges."
)
default_enabled = True
def __init__(self):
super().__init__(name="presence_relay", interval_seconds=2)
self._online: set[str] = set()
self._published: dict[str, bool] = {}
self._roster_uids: Optional[frozenset] = None
async def run_once(self) -> None:
if "users" not in db.tables:
return
dot_pairs: list[tuple[str, str]] = []
roster_subscribed = False
for entry in pubsub.topics():
topic = entry["topic"]
if not entry["subscribers"] or "*" in topic:
continue
if topic == ROSTER_TOPIC:
roster_subscribed = True
continue
match = TOPIC_PATTERN.match(topic)
if match is not None:
dot_pairs.append((topic, match.group("uid")))
roster_rows = presence.online_candidates() if roster_subscribed else []
rows: dict[str, dict] = {}
dot_uids = {uid for _, uid in dot_pairs}
if dot_uids:
rows.update(get_users_by_uids(list(dot_uids)))
for row in roster_rows:
rows[row["uid"]] = row
prev = self._online
online: set[str] = set()
for uid, row in rows.items():
elapsed = presence.seconds_since(row.get("last_seen"))
if presence.stays_online(elapsed, uid in prev):
online.add(uid)
self._online = online
await self._publish_dots(dot_pairs, online, rows)
await self._publish_roster(roster_subscribed, roster_rows, online)
async def _publish_dots(self, dot_pairs, online, rows) -> None:
active = {topic for topic, _ in dot_pairs}
self._published = {t: v for t, v in self._published.items() if t in active}
published = 0
for topic, uid in dot_pairs:
is_on = uid in online
if self._published.get(topic) == is_on:
continue
row = rows.get(uid) or {}
published += await pubsub_publish(
topic, {"online": is_on, "last_seen": row.get("last_seen")}
)
self._published[topic] = is_on
if published:
self.log(f"pushed {published} presence change(s)")
async def _publish_roster(self, subscribed, roster_rows, online) -> None:
if not subscribed:
self._roster_uids = None
return
users = [row for row in roster_rows if row["uid"] in online][:PRESENCE_ONLINE_LIMIT]
uids = frozenset(row["uid"] for row in users)
if uids == self._roster_uids:
return
self._roster_uids = uids
await pubsub_publish(
ROSTER_TOPIC,
{
"count": len(users),
"users": [
{
"uid": row["uid"],
"username": row["username"],
"avatar_seed": row.get("avatar_seed") or row["username"],
}
for row in users
],
},
)
self.log(f"roster changed: {len(users)} online")
def collect_metrics(self) -> dict:
return {
"online": len(self._online),
"tracked_dots": len(self._published),
}
+26
View File
@@ -533,6 +533,32 @@ img {
.avatar-lg { width: 80px; height: 80px; font-size: 2rem; }
.avatar-img { border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.user-avatar-link,
.avatar-badge {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.presence-dot {
position: absolute;
right: 0;
bottom: 0;
width: 30%;
height: 30%;
min-width: 8px;
min-height: 8px;
max-width: 14px;
max-height: 14px;
border-radius: 50%;
background: var(--text-muted);
border: 2px solid var(--bg-card);
box-sizing: border-box;
pointer-events: none;
}
.presence-dot.online { background: var(--success, #2f9e44); }
.topnav {
position: fixed;
top: 0;
+29
View File
@@ -277,6 +277,35 @@
font-weight: 600;
}
.online-count {
display: inline-block;
min-width: 1.25rem;
padding: 0 0.375rem;
border-radius: 999px;
background: var(--success, #2f9e44);
color: var(--white);
font-size: 0.6875rem;
text-align: center;
vertical-align: middle;
}
.online-users-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.online-user {
display: inline-flex;
flex-shrink: 0;
}
.online-empty {
color: var(--text-muted);
font-size: 0.8125rem;
}
.community-stats {
background: var(--bg-card);
border: 1px solid var(--border);
+25
View File
@@ -41,6 +41,31 @@
word-break: break-word;
}
.profile-presence {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.profile-presence.online {
color: var(--success, #2f9e44);
}
.profile-presence::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
margin-right: 0.375rem;
vertical-align: middle;
}
.profile-presence.online::before {
background: var(--success, #2f9e44);
}
.profile-stats {
display: flex;
justify-content: center;
+4
View File
@@ -38,6 +38,8 @@ import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
import { PubSubClient } from "./PubSubClient.js";
import { LiveNotifications } from "./LiveNotifications.js";
import { PresenceManager } from "./PresenceManager.js";
import { OnlineUsers } from "./OnlineUsers.js";
import { LocalTime } from "./LocalTime.js";
import { GameFarm } from "./GameFarm.js";
import { Accessibility } from "./Accessibility.js";
@@ -87,6 +89,8 @@ class Application {
this.planningGenerator = new PlanningGenerator();
this.mediaGallery = new MediaGallery();
this.liveNotifications = new LiveNotifications(this.pubsub, this.toast);
this.presence = new PresenceManager(this.pubsub);
this.onlineUsers = new OnlineUsers(this.pubsub);
this.localTime = new LocalTime();
this.gameFarm = new GameFarm();
}
-37
View File
@@ -19,7 +19,6 @@ export class MessagesLayout {
this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
this._uploading = false;
this._pendingSends = new Set();
this.presenceEl = document.getElementById("messages-presence");
this.typingEl = document.getElementById("typing-indicator");
this.selfUid = this.layout.dataset.selfUid || "";
@@ -34,7 +33,6 @@ export class MessagesLayout {
this.input.focus({ preventScroll: true });
}
this.initPresence();
this.connect();
this.bindForm();
this.bindTyping();
@@ -119,7 +117,6 @@ export class MessagesLayout {
onReady() {
this._socketReady = true;
if (this.otherUid) {
this.socket.send({ type: "presence", with_uid: this.otherUid });
this.markRead();
}
}
@@ -135,11 +132,6 @@ export class MessagesLayout {
case "read":
if (frame.by_uid === this.otherUid) this.markReceipts();
break;
case "presence":
if (frame.user_uid === this.otherUid) {
this.renderPresence(frame.online, frame.last_seen);
}
break;
default:
break;
}
@@ -423,35 +415,6 @@ export class MessagesLayout {
}, TYPING_HIDE_MS);
}
initPresence() {
if (!this.presenceEl) return;
const online = this.layout.dataset.otherOnline === "1";
const lastSeen = this.layout.dataset.otherLastSeen || null;
this.renderPresence(online, lastSeen);
}
renderPresence(online, lastSeen) {
if (!this.presenceEl) return;
this.presenceEl.classList.toggle("online", !!online);
if (online) {
this.presenceEl.textContent = "online";
} else if (lastSeen) {
this.presenceEl.textContent = "last seen " + this.formatLastSeen(lastSeen);
} else {
this.presenceEl.textContent = "offline";
}
}
formatLastSeen(iso) {
const then = new Date(iso);
if (Number.isNaN(then.getTime())) return "recently";
const seconds = Math.max(0, Math.floor((Date.now() - then.getTime()) / 1000));
if (seconds < 60) return "just now";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return then.toLocaleDateString("en-GB");
}
bumpConversation(frame, mine) {
const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
const item = document.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
+55
View File
@@ -0,0 +1,55 @@
// retoor <retoor@molodetz.nl>
const ROSTER_TOPIC = "public.presence.roster";
export class OnlineUsers {
constructor(pubsub) {
this.list = document.querySelector("[data-online-users-list]");
if (!this.list) return;
this.countEl = document.querySelector("[data-online-count]");
this.emptyText = this.list.dataset.emptyText || "No one online right now";
if (pubsub) {
pubsub.subscribe(ROSTER_TOPIC, (data) => this.render(data));
}
}
render(data) {
if (!data || !Array.isArray(data.users)) return;
if (this.countEl) {
this.countEl.textContent = data.count != null ? data.count : data.users.length;
}
this.list.textContent = "";
if (!data.users.length) {
const empty = document.createElement("span");
empty.className = "online-empty";
empty.textContent = this.emptyText;
this.list.appendChild(empty);
return;
}
for (const user of data.users) {
this.list.appendChild(this.buildItem(user));
}
}
buildItem(user) {
const seed = user.avatar_seed || user.username;
const link = document.createElement("a");
link.href = "/profile/" + user.username;
link.className = "online-user";
link.title = user.username;
const badge = document.createElement("span");
badge.className = "avatar-badge";
const img = document.createElement("img");
img.className = "avatar-img avatar-sm";
img.src = "/avatar/multiavatar/" + encodeURIComponent(seed) + "?size=32";
img.alt = user.username;
img.loading = "lazy";
const dot = document.createElement("span");
dot.className = "presence-dot online";
dot.setAttribute("aria-hidden", "true");
badge.appendChild(img);
badge.appendChild(dot);
link.appendChild(badge);
return link;
}
}
+98
View File
@@ -0,0 +1,98 @@
// retoor <retoor@molodetz.nl>
const TOPIC_PREFIX = "public.presence.";
const REFRESH_MS = 20000;
export class PresenceManager {
constructor(pubsub) {
this.pubsub = pubsub;
const seconds = parseInt(document.body.dataset.presenceTimeout, 10);
this.timeoutMs = (Number.isFinite(seconds) && seconds > 0 ? seconds : 60) * 1000;
this.tracked = new Map();
this.scan(document);
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) this.scan(node);
}
}
});
if (document.body) {
this.observer.observe(document.body, { childList: true, subtree: true });
}
window.setInterval(() => this.refresh(), REFRESH_MS);
}
scan(root) {
const targets = [];
if (root.matches && root.matches("[data-presence-uid]")) targets.push(root);
if (root.querySelectorAll) {
root.querySelectorAll("[data-presence-uid]").forEach((el) => targets.push(el));
}
for (const el of targets) this.track(el);
}
track(el) {
const uid = el.dataset.presenceUid;
if (!uid) return;
let entry = this.tracked.get(uid);
if (!entry) {
entry = { els: new Set(), lastSeen: null, online: null };
this.tracked.set(uid, entry);
if (this.pubsub) {
this.pubsub.subscribe(TOPIC_PREFIX + uid, (data) => {
this.update(uid, data);
});
}
}
if (entry.els.has(el)) return;
entry.els.add(el);
const seed = el.dataset.presenceLastSeen || null;
if (seed) entry.lastSeen = seed;
this.render(el, entry);
}
update(uid, data) {
const entry = this.tracked.get(uid);
if (!entry || !data) return;
if (data.online != null) entry.online = !!data.online;
if (data.last_seen) entry.lastSeen = data.last_seen;
for (const el of entry.els) this.render(el, entry);
}
isOnline(lastSeen) {
if (!lastSeen) return false;
const then = new Date(lastSeen).getTime();
if (Number.isNaN(then)) return false;
return Date.now() - then < this.timeoutMs;
}
render(el, entry) {
const online = entry.online != null ? entry.online : this.isOnline(entry.lastSeen);
el.classList.toggle("online", online);
const label = online
? "online"
: entry.lastSeen
? "last seen " + this.formatLastSeen(entry.lastSeen)
: "offline";
el.title = label;
if (el.hasAttribute("data-presence-label")) el.textContent = label;
}
formatLastSeen(iso) {
const then = new Date(iso);
if (Number.isNaN(then.getTime())) return "recently";
const seconds = Math.max(0, Math.floor((Date.now() - then.getTime()) / 1000));
if (seconds < 60) return "just now";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return then.toLocaleDateString("en-GB");
}
refresh() {
if (document.hidden) return;
for (const entry of this.tracked.values()) {
for (const el of entry.els) this.render(el, entry);
}
}
}
+1
View File
@@ -1,5 +1,6 @@
{% if _user %}
<a href="/profile/{{ _user['username'] }}" class="user-avatar-link">
<img src="{{ avatar_url('multiavatar', avatar_seed(_user), _size) }}" class="avatar-img avatar-{{ _size_class }}" alt="{{ _user['username'] }}" loading="lazy">
{% include "_presence_dot.html" %}
</a>
{% endif %}
+1
View File
@@ -0,0 +1 @@
{% if _user and _user.get('uid') %}<span class="presence-dot{% if is_online(_user) %} online{% endif %}" data-presence-uid="{{ _user['uid'] }}" data-presence-last-seen="{{ _user.get('last_seen') or '' }}" aria-hidden="true"></span>{% endif %}
+3 -3
View File
@@ -60,7 +60,7 @@
</script>
{% endif %}
</head>
<body data-user-uid="{{ user['uid'] if user else '' }}">
<body data-user-uid="{{ user['uid'] if user else '' }}" data-presence-timeout="{{ presence_timeout }}">
<a class="skip-link" href="#main-content">Skip to main content</a>
<nav class="topnav" aria-label="Primary">
<div class="topnav-inner">
@@ -103,7 +103,7 @@
{% endif %}
<div class="topnav-user-dropdown">
<button type="button" class="topnav-user" aria-label="User menu" aria-expanded="false" aria-controls="user-menu">
<img src="{{ avatar_url('multiavatar', avatar_seed(user), 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
<span class="avatar-badge"><img src="{{ avatar_url('multiavatar', avatar_seed(user), 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">{% set _user = user %}{% include "_presence_dot.html" %}</span>
<div class="topnav-user-info">
<span class="topnav-user-name">{{ user['username'] }}</span>
<span class="topnav-user-level">Level {{ user.get('level', 1) }}</span>
@@ -160,7 +160,7 @@
{% endif %}
<div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-user">
<img src="{{ avatar_url('multiavatar', avatar_seed(user), 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
<span class="avatar-badge"><img src="{{ avatar_url('multiavatar', avatar_seed(user), 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">{% set _user = user %}{% include "_presence_dot.html" %}</span>
<div class="topnav-mobile-user-info">
<span>{{ user['username'] }}</span>
<span class="topnav-mobile-user-level">Level {{ user.get('level', 1) }}</span>
+42
View File
@@ -0,0 +1,42 @@
<div class="docs-content" data-render>
{% raw %}
# Online presence
DevPlace shows whether a user is **online** right now. You see it on every profile page and at the top of a direct-message conversation, as a small status dot with a short label: `online`, `last seen 5m ago`, or `offline`.
## What you see
- Every user **avatar** carries a small dot in its bottom-right corner: **green** when the person is online, **muted grey** when they are not, so presence is visible everywhere an avatar appears (the feed, comments, the navbar, message lists, and more). Hover the dot for the exact status.
- On a **profile** and at the top of a **conversation** you also get the word `online`, or **`last seen ...`** with a relative time, or `offline` if the person has not been seen since the feature started tracking them.
- The indicator **updates on its own** while you have the page open: if someone comes online while you are looking at their profile, the dot turns green within a few seconds, and it fades back to grey shortly after they go idle. You never need to refresh.
- The **feed** shows an **Online now** panel at the bottom of the left sidebar: the avatars of everyone currently online, ordered alphabetically so they keep a stable spot, with a live count. People appear and disappear from it in real time as they come and go, again with no refresh.
"Active" simply means loading any page on the site. There is nothing to switch on and no busy or away status to set; presence is automatic.
## How long you stay "online"
You count as online for a short window after your last activity. The default window is **60 seconds**, so a moment after you stop browsing you quietly drop to `last seen ...`. Because the window is deliberately short, the status is honest: a green dot means the person really is here right now, not that they logged in hours ago. Operators can change the window, so on some deployments it may be longer or shorter.
To keep the indicator steady rather than jittery, the status is **quick to turn on and slow to turn off**: you show as online the moment you are active, but a brief pause does not immediately drop you - there is a short grace margin before the dot goes grey. This stops the flicker you would otherwise see if your activity landed right on the edge of the window, and it applies to both the avatar dots and the Online now list, so they always agree.
## Privacy
Online status is public, the same way your posts and profile are public. It reflects only activity on DevPlace and never your location, your device, or anything you do elsewhere. The only thing recorded is the time you were last active, and it is overwritten in place each time, never kept as a history.
## For contributors
Presence is one timestamp plus a lightweight push, built to touch the database as little as possible.
**The data.** Each user row carries a single `last_seen` column (UTC ISO). There is no separate table and no per-request insert, so the feature adds no data growth. A user is online when `now - last_seen` is under `config.PRESENCE_TIMEOUT_SECONDS` (env `DEVPLACE_PRESENCE_TIMEOUT_SECONDS`, default 60).
**Writing it.** The `track_presence` middleware in `main.py` resolves the current user on each non-asset request and calls `services/presence.py` `touch(uid)`. `touch` keeps a per-worker in-memory throttle and writes `last_seen` at most once per `PRESENCE_WRITE_SECONDS` (half the window) per user, as an in-place `UPDATE`. Continuous browsing is a dictionary lookup, not a write. This is the only cross-worker-correct approach here: pub/sub is in-process, so SQLite is the shared medium.
**Reading it.** `presence.is_online(user_row)` is exposed as the Jinja global `is_online(user)` and rendered from the user row a page already loaded, so no extra query runs. The value is also on `UserOut.last_seen` and `ProfileOut.profile_online` for JSON clients.
**Live updates (change-only, hysteresis).** `PresenceRelayService` (`services/presence_relay.py`) runs on the service-lock owner and recomputes ONE global online set each tick that drives BOTH the avatar dots and the feed roster, so they can never disagree. Membership uses hysteresis (`presence.stays_online`): a user goes online at `PRESENCE_TIMEOUT_SECONDS` but only drops after an extra `PRESENCE_ONLINE_MARGIN_SECONDS` grace (`DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS`, default 20) - quick on, slow off - which is what prevents boundary flicker. It pushes to `public.presence.{uid}` **only when a watched user's online bool actually changes** (or a new subscriber first appears) - never on a fixed interval - reading watched users in **one batched query per tick**, so an idle page costs zero messages. The frontend `PresenceManager` (`static/js/PresenceManager.js`, `app.presence`) subscribes any element carrying `data-presence-uid` (deduped per uid) and treats each frame's `online` flag as **authoritative** - it does not expire a live dot from its own clock, so an active user never flickers to grey. The staleness timer remains only as a fallback for elements with no live subscription (e.g. guests). Reuse `is_online`, `presence.stays_online`, the `public.presence.{uid}` topic, and `PresenceManager` for any new online indicator rather than re-implementing presence.
**Online-now roster.** The same relay maintains one shared topic, `public.presence.roster`, republished **only when the set of online users changes** (a `frozenset` compare, so reordering never republishes). `services/presence.py` `online_users()`/`online_candidates()` read it with an indexed `last_seen` query (`database.get_online_users`; `idx_users_last_seen`), ordered **alphabetically by username** so an avatar keeps a stable position instead of jumping around as activity ticks. The feed renders the initial list server-side and `static/js/OnlineUsers.js` (`app.onlineUsers`) subscribes to the topic and re-renders the avatar list live. Roster avatars are online by definition, so their dots are a plain green `.presence-dot` with no per-user subscription - list membership *is* the presence.
**The avatar dot.** The corner dot is one reusable partial, `templates/_presence_dot.html`, which emits a `<span class="presence-dot" data-presence-uid=... data-presence-last-seen=...>` (guarded on `uid`, so an author with no resolvable user renders no dot). It is included by the shared avatar partial `templates/_avatar_link.html` (which covers most avatars) and by a handful of raw-avatar sites wrapped in a positioned `.avatar-badge` span. Because the dot carries `data-presence-uid` but no `data-presence-label`, `PresenceManager` drives its colour with **no extra JavaScript**. The dot sizes itself as a percentage of the avatar (clamped 8-14px), so it stays proportional at every avatar size. Reuse `is_online`, `_presence_dot.html`, the `public.presence.{uid}` topic, and `PresenceManager` for any new online indicator rather than re-implementing presence.
{% endraw %}
</div>
+13
View File
@@ -60,6 +60,19 @@
</a>
</div>
</div>
<div class="sidebar-section online-users" aria-label="Online now">
<div class="sidebar-heading">Online now <span class="online-count" data-online-count>{{ online_users|length }}</span></div>
<div class="online-users-list" data-online-users-list data-empty-text="No one online right now">
{% for ou in online_users %}
<a href="/profile/{{ ou['username'] }}" class="online-user" title="{{ ou['username'] }}">
<span class="avatar-badge"><img src="{{ avatar_url('multiavatar', avatar_seed(ou), 32) }}" class="avatar-img avatar-sm" alt="{{ ou['username'] }}" loading="lazy"><span class="presence-dot online" aria-hidden="true"></span></span>
</a>
{% else %}
<span class="online-empty">No one online right now</span>
{% endfor %}
</div>
</div>
</aside>
<div class="feed-main">
+3 -3
View File
@@ -6,7 +6,7 @@
{% block content %}
<h1 class="sr-only">Messages</h1>
<div class="page-messages">
<div class="messages-layout" data-self-uid="{{ user['uid'] }}"{% if current_conversation %} data-other-uid="{{ current_conversation }}" data-other-online="{{ '1' if other_online else '0' }}"{% if other_last_seen %} data-other-last-seen="{{ other_last_seen }}"{% endif %}{% endif %}>
<div class="messages-layout" data-self-uid="{{ user['uid'] }}"{% if current_conversation %} data-other-uid="{{ current_conversation }}"{% endif %}>
<div class="messages-list" role="navigation" aria-label="Conversations">
<div class="messages-list-header" role="search">
<input type="text" id="message-search" placeholder="Search conversations..." value="{{ search }}" aria-label="Search conversations">
@@ -15,7 +15,7 @@
<div class="messages-conversations" role="list" aria-label="Conversation list">
{% for conv in conversations %}
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}" data-conv-uid="{{ conv.other_user['uid'] }}" role="listitem"{% if current_conversation == conv.other_user['uid'] %} aria-current="true"{% endif %}>
<img src="{{ avatar_url('multiavatar', avatar_seed(conv.other_user), 32) }}" class="avatar-img avatar-sm" alt="{{ conv.other_user['username'] }}" loading="lazy">
<span class="avatar-badge"><img src="{{ avatar_url('multiavatar', avatar_seed(conv.other_user), 32) }}" class="avatar-img avatar-sm" alt="{{ conv.other_user['username'] }}" loading="lazy">{% set _user = conv.other_user %}{% include "_presence_dot.html" %}</span>
<div class="conversation-info">
<div class="conversation-name">{{ conv.other_user['username'] }}</div>
<div class="conversation-preview">{{ content_preview(conv.last_message, 60) }}</div>
@@ -36,7 +36,7 @@
{% set _user = other_user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<div class="messages-header-info">
<h3>{% set _user = other_user %}{% set _class = none %}{% include "_user_link.html" %}</h3>
<span class="messages-presence" id="messages-presence" role="status" aria-live="polite"></span>
<span class="messages-presence{% if is_online(other_user) %} online{% endif %}" id="messages-presence" role="status" aria-live="polite" data-presence-uid="{{ other_user['uid'] }}" data-presence-last-seen="{{ other_user.get('last_seen') or '' }}" data-presence-label>{% if is_online(other_user) %}online{% elif other_user.get('last_seen') %}last seen {{ format_date(other_user['last_seen']) }}{% else %}offline{% endif %}</span>
</div>
</div>
+4 -2
View File
@@ -10,13 +10,15 @@
<aside class="profile-sidebar">
<div class="profile-card">
<div class="profile-avatar-wrap">
<img src="{{ avatar_url('multiavatar', avatar_seed(profile_user), 80) }}" class="avatar-img avatar-lg" alt="{{ profile_user['username'] }}" id="profile-avatar-preview" loading="lazy">
<span class="avatar-badge"><img src="{{ avatar_url('multiavatar', avatar_seed(profile_user), 80) }}" class="avatar-img avatar-lg" alt="{{ profile_user['username'] }}" id="profile-avatar-preview" loading="lazy">{% set _user = profile_user %}{% include "_presence_dot.html" %}</span>
{% if is_owner or viewer_is_admin %}
<button type="button" class="btn btn-sm avatar-regen-btn" data-regenerate-avatar data-username="{{ profile_user['username'] }}">Regenerate avatar</button>
{% endif %}
</div>
<h1 class="profile-name">{{ profile_user['username'] }}</h1>
<div class="profile-presence{% if profile_online %} online{% endif %}" role="status" data-presence-uid="{{ profile_user['uid'] }}" data-presence-last-seen="{{ profile_user.get('last_seen') or '' }}" data-presence-label>{% if profile_online %}online{% elif profile_user.get('last_seen') %}last seen {{ format_date(profile_user['last_seen']) }}{% else %}offline{% endif %}</div>
<div class="profile-stats">
<div class="profile-stat">
<span class="profile-stat-value">{{ posts_count }}</span>
@@ -549,7 +551,7 @@
{% for person in people %}
<div class="follow-row">
<a href="/profile/{{ person['username'] }}" class="follow-user">
<img src="{{ avatar_url('multiavatar', avatar_seed(person), 40) }}" class="avatar-img avatar-md" alt="{{ person['username'] }}" loading="lazy">
<span class="avatar-badge"><img src="{{ avatar_url('multiavatar', avatar_seed(person), 40) }}" class="avatar-img avatar-md" alt="{{ person['username'] }}" loading="lazy">{% set _user = person %}{% include "_presence_dot.html" %}</span>
<div class="follow-user-info">
<span class="follow-user-name">{{ person['username'] }}</span>
{% if person['bio'] %}<span class="follow-user-bio rendered-title">{{ render_title(person['bio']) }}</span>{% endif %}
+4 -1
View File
@@ -6,7 +6,7 @@ import jinja2
from fastapi.templating import Jinja2Templates
from markupsafe import Markup, escape
from devplacepy.cache import TTLCache
from devplacepy.config import STATIC_VERSION, TEMPLATES_DIR, TEMPLATE_AUTO_RELOAD
from devplacepy.config import STATIC_VERSION, TEMPLATES_DIR, TEMPLATE_AUTO_RELOAD, PRESENCE_TIMEOUT_SECONDS
from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.database import get_int_setting, get_setting, get_table
from devplacepy.avatar import avatar_url, avatar_seed
@@ -16,6 +16,7 @@ from devplacepy.utils import get_badge, is_admin, is_primary_admin, pretty_json
from devplacepy.attachments import format_file_size, file_icon_emoji
from devplacepy.content import is_owner as _owns
from devplacepy.customization import custom_css_tag, custom_js_tag, page_type_for
from devplacepy.services import presence
EM_DASH_FORMS = ("\u2014", "&mdash;", "&#8212;", "&#x2014;", "&#X2014;")
@@ -64,6 +65,8 @@ templates.env.globals["is_primary_admin"] = is_primary_admin
templates.env.globals["owns"] = _owns
templates.env.globals["is_self"] = is_self
templates.env.globals["guest_disabled"] = guest_disabled
templates.env.globals["is_online"] = presence.is_online
templates.env.globals["presence_timeout"] = PRESENCE_TIMEOUT_SECONDS
from devplacepy.docs_devrant import devrant_endpoints