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