forked from retoor/devplacepy
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:
@@ -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,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",
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ()))
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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", "—", "—", "—", "—")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user