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