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