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
+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,