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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user