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
+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);
}
}
}