// ── Collab Buckets Plugin Frontend ──
//
// Phase 1+2 shape (post-refactor): collab buckets are first-class bucket
// rows in the standard bucket bar. The plugin just owns the sync + the
// visual unread-state decoration:
//
// - Polls /api/collab-buckets/poll on every `sync.tick` (shared timer
// in sync.js). The backend mirrors each remote bucket into the main
// `buckets` table so the core bucket bar renders it automatically.
// - Exposes `window._collabBucketsCreateDialog` — an async function that
// the core + Bucket button calls so we can pop a richer "new bucket"
// modal with an optional "collaborative" checkbox + member picker.
// - Watches #bucket-bar via MutationObserver. After every render, finds
// tabs whose data-bucket id corresponds to a collab bucket and attaches
// a pulse animation + red unread dot if the bucket has unread state.
// - Delegated click handler on #bucket-bar calls mark-seen for collab tabs.
//
// The plugin never touches the header anymore. No more standalone tiles.
const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[collab-buckets] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
// uid -> collab bucket meta (from /api/collab-buckets)
let collabByUid = new Map();
// card_id (number) -> true for cards with foreign edits we haven't opened yet
let unreadCards = new Set();
// Collab card sync is driven by the shared `sync.tick` hook from
// sync.js — one timer fans out to every plugin poller, the central
// timer honors the Autosync pause for us.
// Phase 4: edit locks (now exclusive — server-enforced)
// card_uid -> { bucket_uid, holder_username, expires_at }
let collabLocks = new Map();
// Tracks the editor session: { bucket_uid, card_uid, cardId, hbTimer,
// inputCleanup, acquired, takeover }
let editorSession = null;
const LOCK_HEARTBEAT_MS = 30000;
// ── Helpers ───────────────────────────────────────────────────────
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
// ── Loading / polling ─────────────────────────────────────────────
async function loadCollabMeta() {
try {
const res = await fetch('api/collab-buckets').then(r => r.json());
if (!res?.ok) return;
const next = new Map();
for (const b of res.buckets || []) next.set(b.uid, b);
collabByUid = next;
// Expose for other plugin code + for debugging
window._collabBucketsByUid = collabByUid;
decorateBucketBar();
} catch (err) { /* silent; relay may be offline */ }
}
let pollLocked = false;
async function pollOnce() {
if (pollLocked) return;
// Don't poll while user is actively editing — the push would race
// with the in-progress save and corrupt card state.
if (document.getElementById('modal')?.classList.contains('open')) return;
try {
pollLocked = true;
hyprcrd.hooks?.emit?.('sync.pulse');
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
if (!res?.ok) return;
if (res.self) window._collabSelf = res.self;
// Reload if:
// * a bucket changed (metadata / members)
// * the bucket set shrunk or grew
// * card sync pushed / pulled / deleted anything (Phase 3)
const bucketChanged = res.changed > 0 || res.total !== collabByUid.size;
const cardsChanged = !!res.cards_changed;
if (bucketChanged || cardsChanged) {
await loadCollabMeta();
if (typeof window._reloadAll === 'function') await window._reloadAll();
}
// If LWW clobbered local edits, the backend stashed them in a local
// "Conflicts" bucket — surface that visibly so the user knows.
const conflicts = res.card_stats?.conflicts || 0;
if (conflicts > 0) {
showConflictToast(conflicts);
}
// Phase 5: if a queued bucket create finally pushed through, tell
// the user. Retry failures stay silent.
if ((res.outbox_processed || 0) > 0) {
const n = res.outbox_processed;
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
}
// Per-card unread list: cards with foreign edits we haven't opened
// yet. Decorated on the board via decorateBoardUnread().
if (Array.isArray(res.unread_cards)) {
unreadCards = new Set(res.unread_cards.map(Number));
decorateBoardUnread();
}
// Phase 4: edit-lock list comes back with the poll
if (Array.isArray(res.locks)) {
const next = new Map();
for (const l of res.locks) next.set(l.card_uid, l);
collabLocks = next;
window._collabLocks = collabLocks;
decorateBoardLocks();
// If the editor is currently open on a card that just got
// locked by someone else, take over immediately. Covers the
// race where two users started typing nearly simultaneously
// and the relay arbitration only resolves on the next poll.
if (editorSession && !editorSession.takeover) {
const lock = collabLocks.get(editorSession.card_uid);
const self = window._collabSelf || null;
if (lock && lock.holder_username && lock.holder_username !== self) {
enterEditorTakeover(lock.holder_username);
}
}
}
// The push phase couldn't deliver because someone else holds an
// exclusive lock on a dirty card. Surface so the user knows their
// edit isn't lost — it's just queued.
const blocked = res.card_stats?.blocked || 0;
if (blocked > 0) {
const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : [];
showBlockedToast(by);
}
} catch { /* silent */ } finally { pollLocked = false; }
}
function showConflictToast(n) {
const existing = document.getElementById('collab-conflict-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'collab-conflict-toast';
toast.className = 'collab-conflict-toast';
toast.innerHTML = `
${n} local change${n === 1 ? '' : 's'} overwritten by a collaborator — backup in the Conflicts bucket.
`;
document.body.appendChild(toast);
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 10000);
}
/** Informational toast — used when the relay is unreachable and a bucket
* create was queued, or when the outbox retry succeeds later. */
function showQueuedToast(message) {
const existing = document.getElementById('collab-queued-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'collab-queued-toast';
toast.className = 'collab-conflict-toast collab-queued-toast';
toast.innerHTML = `
${escapeHtml(message)}
`;
document.body.appendChild(toast);
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
}
// Subscribe to the global sync heartbeat. The central timer in sync.js
// honors the Autosync pause + the configurable interval for us, and
// the header Sync button emits the same tick so manual sync pushes
// collab cards too.
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
// ── Bucket bar decoration ─────────────────────────────────────────
/**
* For every tab in #bucket-bar whose bucket has a collab_uid, apply
* collab-specific classes (.bucket-tab-collab, .has-unread, .pulsing)
* and inject an unread dot. Runs after every core re-render via the
* MutationObserver below.
*/
function decorateBucketBar() {
const bar = document.getElementById('bucket-bar');
if (!bar || !window.state?.buckets) return;
// Build bucket-id -> row map so we can look up collab_uid
const byId = new Map();
for (const b of window.state.buckets) byId.set(String(b.id), b);
for (const tab of bar.querySelectorAll('.bucket-tab[data-bucket]')) {
const bid = tab.dataset.bucket;
const row = byId.get(bid);
if (!row || !row.collab_uid) continue;
tab.classList.add('bucket-tab-collab');
tab.dataset.collabUid = row.collab_uid;
// Replace the default folder icon with the "users" icon so the tab
// reads as collaborative at a glance. We query by class and swap
// the phosphor icon class set. Safe to run on every render — the
// "icon swap" is idempotent.
const icon = tab.querySelector('i.ph');
if (icon && !icon.classList.contains('ph-users-three')) {
icon.classList.remove('ph-folder');
icon.classList.add('ph-users-three');
}
// Unread state
const meta = collabByUid.get(row.collab_uid);
const wasUnread = tab.classList.contains('has-unread');
const isUnread = !!meta?.has_unread;
tab.classList.toggle('has-unread', isUnread);
// Inject / remove dot element
let dot = tab.querySelector('.collab-tab-dot');
if (isUnread && !dot) {
dot = document.createElement('span');
dot.className = 'collab-tab-dot';
tab.appendChild(dot);
} else if (!isUnread && dot) {
dot.remove();
}
// Fresh unread -> pulse 3x. JS strips the class after 3.7s so
// subsequent decorate-calls don't retrigger mid-animation.
if (isUnread && !wasUnread) {
tab.classList.add('pulsing');
setTimeout(() => tab.classList.remove('pulsing'), 3700);
}
}
}
// ── Mark-seen on click (delegated, fires after core's click handler) ──
function setupClickInterceptor() {
const bar = document.getElementById('bucket-bar');
if (!bar) return;
bar.addEventListener('click', async (e) => {
const tab = e.target.closest('.bucket-tab[data-bucket]');
if (!tab) return;
const uid = tab.dataset.collabUid;
if (!uid) return;
// Clear unread locally immediately for snappy feedback
const meta = collabByUid.get(uid);
if (meta) meta.has_unread = false;
tab.classList.remove('has-unread', 'pulsing');
tab.querySelector('.collab-tab-dot')?.remove();
// Persist to the plugin backend
hyprcrd.api('api/collab-buckets/mark-seen', { method: 'POST', body: { uid } }).catch(() => {});
});
}
// ── MutationObserver: redecorate on every bar re-render ───────────
function setupBarObserver() {
const bar = document.getElementById('bucket-bar');
if (!bar) return;
const obs = new MutationObserver(() => decorateBucketBar());
obs.observe(bar, { childList: true, subtree: true });
}
// ── Create dialog (hooked by core's + Bucket button) ──────────────
async function fetchRelayTenants() {
try {
const res = await hyprcrd.api('api/collab-buckets/relay-tenants');
return res?.ok ? res : null;
} catch { return null; }
}
/**
* Shown when the user clicks the core "+ Bucket" button. Returns true when
* a bucket was created (so core can reload) and false / null when canceled.
*/
async function createBucketDialog() {
// Lazy-load the relay tenants so a local-only bucket doesn't pay for
// the round trip when the user just wants a simple bucket.
const relayP = fetchRelayTenants();
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'collab-modal-overlay';
overlay.innerHTML = `
';
return;
}
membersList.innerHTML = peers.map(t => {
const u = typeof t === 'string' ? t : (t.username || '');
return ``;
}).join('');
});
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
const name = overlay.querySelector('#__cb_name').value.trim();
const color = overlay.querySelector('#__cb_color').value;
const isCollab = collabEl.checked;
if (!name) { status.textContent = 'name required'; status.style.color = 'var(--t-danger)'; return; }
status.textContent = 'creating…';
status.style.color = 'var(--t-dim)';
try {
if (isCollab) {
const members = [...overlay.querySelectorAll('#__cb_members_list input:checked')].map(c => c.value);
const res = await hyprcrd.api('api/collab-buckets', { method: 'POST', body: { name, color, members } });
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
// Relay was unreachable; the bucket exists locally and the
// upgrade is queued. Show a toast so the user knows.
if (res.queued) {
showQueuedToast('Bucket created locally — will sync to the relay on the next successful poll.');
}
} else {
// Regular bucket: POST /api/buckets (core route), then PATCH color.
const created = await hyprcrd.api('api/buckets', { method: 'POST', body: { name } });
if (created?.id && color) {
await hyprcrd.api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color } });
}
}
await loadCollabMeta();
done(true);
} catch (err) {
status.textContent = String(err?.message || err);
status.style.color = 'var(--t-danger)';
}
});
// Enter in the name field = Create (users expect the submit path
// without reaching for the mouse). Stop propagation so nothing
// upstream grabs it. No-op if name is empty — the submit button's
// own handler validates.
overlay.querySelector('#__cb_name').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
overlay.querySelector('.collab-btn-submit').click();
}
});
setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50);
});
}
// Expose for core's + Bucket handler
window._collabBucketsCreateDialog = createBucketDialog;
/**
* Edit dialog for an existing collab bucket. Shown by the core bucket
* context menu's "Members & owner…" item. The current owner sees a
* member picker + an owner dropdown; other members see everything as
* read-only labels since only the owner can push updates. Returns true
* when the PATCH succeeded so core can reload.
*/
async function editBucketDialog(collabUid) {
const bucket = collabByUid.get(collabUid);
if (!bucket) return false;
const relay = await fetchRelayTenants();
if (!relay) {
await hyprcrd.dialog.show({
title: 'collab buckets',
message: 'Could not reach the relay.',
okLabel: 'Ok', cancelLabel: '',
});
return false;
}
const self = relay.self || '';
const allTenants = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || ''));
const peers = allTenants.filter(u => u && u !== self);
const isOwner = bucket.owner_username === self;
const currentMembers = new Set((bucket.members || []).filter(u => u && u !== self));
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'collab-modal-overlay';
overlay.innerHTML = `
${escapeHtml(bucket.name)} — members
Owner
${isOwner
? `
Transferring will hand over all edit rights. You stay a member.