plugin(collab-buckets): collaborative buckets via relay

End-to-end shared buckets between relay tenants. Bucket metadata + members
sync through new relay routes (collab-buckets, collab-cards, collab-locks);
card content rides the existing inter-tenant encryption hop. Edit locks,
ownership transfer, LWW conflict stashing, undo for local-bucket destructive
actions, "Make collaborative…" upgrade path, and a resilient outbox for
relay-down create attempts. See plugins/collab-buckets/README.md and
TODO.md for the full picture.

Core hooks added so the plugin can integrate without monkey-patching:
- buckets.js context menu: collab-aware rename/color/delete + Members &
  owner / Make collaborative entries
- editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the
  per-card edit-lock heartbeat
- app.js exposes window.state for the plugin's MutationObserver
- index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab
  buckets directly (must go through the relay)
- Database.php adds buckets.collab_uid column + unique partial index
- import handler now restores color and collab_uid (was silently dropping
  color, which caused the test runner's auto-restore to wipe custom
  bucket colors)
- relay bootstrap creates collab_buckets, collab_bucket_members,
  collab_cards, collab_card_locks tables and prunes tombstones / expired
  locks on every request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-11 18:49:53 +02:00
1 parent a29708df85
commit 26ffa0e71f
16 files changed
+3017 -13

No files matched your search

@@ -0,0 +1,719 @@
// ── 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 every POLL_INTERVAL_MS. 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();
let pollHandle = null;
const POLL_INTERVAL_MS = 30000;
// Phase 4: edit locks
// card_uid -> { bucket_uid, holder_username, expires_at }
let collabLocks = new Map();
// Active heartbeat for the currently open editor (if editing a collab card)
let activeLock = null; // { bucket_uid, card_uid, timer }
const LOCK_HEARTBEAT_MS = 30000;
// ── Helpers ───────────────────────────────────────────────────────
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ── 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 */ }
}
async function pollOnce() {
try {
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
if (!res?.ok) return;
// 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.`);
}
// 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();
}
} catch { /* silent */ }
}
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 = `
<i class="ph ph-warning text-sm"></i>
<span>${n} local change${n === 1 ? '' : 's'} overwritten by a collaborator — backup in the <b>Conflicts</b> bucket.</span>
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
`;
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 = `
<i class="ph ph-cloud-arrow-up text-sm"></i>
<span>${escapeHtml(message)}</span>
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
`;
document.body.appendChild(toast);
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
}
function startPolling() {
stopPolling();
pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
}
// ── 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 = `
<div class="collab-modal">
<div class="collab-modal-header">
<i class="ph ph-folder-plus text-t-accent"></i>
<span class="font-pixel text-xs text-t-accent">new bucket</span>
<div style="flex:1"></div>
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="collab-modal-body">
<label class="collab-field">
<span class="collab-label">Name</span>
<input type="text" class="collab-input" id="__cb_name" placeholder="bucket name...">
</label>
<label class="collab-field">
<span class="collab-label">Color</span>
<input type="color" class="collab-color" id="__cb_color" value="#64748b">
</label>
<label class="collab-field collab-toggle-row">
<input type="checkbox" id="__cb_collab">
<span>Collaborative — share with relay users</span>
</label>
<div class="collab-field collab-members-wrap hidden" id="__cb_members_wrap">
<span class="collab-label">Members</span>
<div class="collab-members" id="__cb_members_list">
<div class="collab-members-empty">loading…</div>
</div>
</div>
</div>
<div class="collab-modal-footer">
<div class="collab-modal-status" id="__cb_status"></div>
<div style="flex:1"></div>
<button class="collab-btn-cancel" type="button">Cancel</button>
<button class="collab-btn-submit" type="button">Create</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const done = (created) => { overlay.remove(); resolve(created); };
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
});
const collabEl = overlay.querySelector('#__cb_collab');
const membersWrap = overlay.querySelector('#__cb_members_wrap');
const membersList = overlay.querySelector('#__cb_members_list');
const status = overlay.querySelector('#__cb_status');
let peers = [];
let tenantsLoaded = false;
collabEl.addEventListener('change', async () => {
if (!collabEl.checked) {
membersWrap.classList.add('hidden');
return;
}
membersWrap.classList.remove('hidden');
if (tenantsLoaded) return;
const relay = await relayP;
tenantsLoaded = true;
if (!relay) {
membersList.innerHTML = '<div class="collab-members-empty">could not reach the relay — uncheck collaborative to create a local bucket</div>';
return;
}
const self = relay.self || '';
peers = (relay.tenants || []).filter(t => (t.username || t) !== self);
if (!peers.length) {
membersList.innerHTML = '<div class="collab-members-empty">no other relay users</div>';
return;
}
membersList.innerHTML = peers.map(t => {
const u = typeof t === 'string' ? t : (t.username || '');
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`;
}).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)';
}
});
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 = `
<div class="collab-modal">
<div class="collab-modal-header">
<i class="ph ph-users-three text-t-accent"></i>
<span class="font-pixel text-xs text-t-accent">${escapeHtml(bucket.name)} — members</span>
<div style="flex:1"></div>
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="collab-modal-body">
<div class="collab-field">
<span class="collab-label">Owner</span>
${isOwner
? `<select class="collab-input" id="__cb_owner">
<option value="${escapeHtml(self)}">${escapeHtml(self)} (you)</option>
${[...currentMembers].sort().map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('')}
</select>
<div class="collab-hint">Transferring will hand over all edit rights. You stay a member.</div>`
: `<div class="collab-readonly">${escapeHtml(bucket.owner_username)}</div>`
}
</div>
<div class="collab-field">
<span class="collab-label">Members (${peers.length} available)</span>
${isOwner
? `<div class="collab-members" id="__cb_edit_members">
${peers.map(u => {
const checked = currentMembers.has(u) ? 'checked' : '';
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}" ${checked}><span>${escapeHtml(u)}</span></label>`;
}).join('') || '<div class="collab-members-empty">no other relay users</div>'}
</div>`
: `<div class="collab-members">
${(bucket.members || []).map(u => `<div class="collab-member-row"><span>${escapeHtml(u)}${u === self ? ' (you)' : ''}</span></div>`).join('')}
</div>
<div class="collab-hint">Only the owner can change members or transfer ownership.</div>`
}
</div>
</div>
<div class="collab-modal-footer">
<div class="collab-modal-status" id="__cb_status"></div>
<div style="flex:1"></div>
<button class="collab-btn-cancel" type="button">${isOwner ? 'Cancel' : 'Close'}</button>
${isOwner ? `<button class="collab-btn-submit" type="button">Save</button>` : ''}
</div>
</div>
`;
document.body.appendChild(overlay);
const done = (changed) => { overlay.remove(); resolve(changed); };
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
});
if (!isOwner) return; // read-only view; no submit handler
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
const newOwner = overlay.querySelector('#__cb_owner').value;
const members = [...overlay.querySelectorAll('#__cb_edit_members input:checked')].map(c => c.value);
const status = overlay.querySelector('#__cb_status');
const payload = { members };
if (newOwner && newOwner !== self) payload.owner = newOwner;
status.textContent = 'saving…';
status.style.color = 'var(--t-dim)';
try {
const res = await hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), {
method: 'PATCH', body: payload,
});
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
await pollOnce();
await loadCollabMeta();
done(true);
} catch (err) {
status.textContent = String(err?.message || err);
status.style.color = 'var(--t-danger)';
}
});
});
}
window._collabBucketsEditDialog = editBucketDialog;
/**
* Upgrade dialog for turning an existing local bucket into a collab
* bucket. The core bucket context menu shows "Make collaborative…" for
* non-collab buckets when the plugin is loaded. The dialog just picks
* members — name/color come from the existing bucket and are reused.
* Returns true on success so core can reload.
*/
async function upgradeBucketDialog(localBucketId) {
const bucket = window.state?.buckets?.find(b => String(b.id) === String(localBucketId));
if (!bucket || bucket.collab_uid) return false;
const relay = await fetchRelayTenants();
if (!relay) {
await hyprcrd.dialog.show({
title: 'collab buckets',
message: 'Could not reach the relay. Check the relay settings first.',
okLabel: 'Ok', cancelLabel: '',
});
return false;
}
const self = relay.self || '';
const peers = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || '')).filter(u => u && u !== self);
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'collab-modal-overlay';
overlay.innerHTML = `
<div class="collab-modal">
<div class="collab-modal-header">
<i class="ph ph-users-three text-t-accent"></i>
<span class="font-pixel text-xs text-t-accent">make "${escapeHtml(bucket.name)}" collaborative</span>
<div style="flex:1"></div>
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="collab-modal-body">
<div class="collab-field">
<div class="collab-hint">This bucket and all its cards will be shared with the members you pick. Existing cards are pushed on the next sync (~30s). You stay the owner.</div>
</div>
<div class="collab-field">
<span class="collab-label">Members (${peers.length} available)</span>
${peers.length === 0
? '<div class="collab-members-empty">no other relay users</div>'
: `<div class="collab-members" id="__cb_up_members">
${peers.map(u => `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`).join('')}
</div>`
}
</div>
</div>
<div class="collab-modal-footer">
<div class="collab-modal-status" id="__cb_status"></div>
<div style="flex:1"></div>
<button class="collab-btn-cancel" type="button">Cancel</button>
<button class="collab-btn-submit" type="button">Make collaborative</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const done = (changed) => { overlay.remove(); resolve(changed); };
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
});
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
const status = overlay.querySelector('#__cb_status');
const members = [...overlay.querySelectorAll('#__cb_up_members input:checked')].map(c => c.value);
status.textContent = 'upgrading…';
status.style.color = 'var(--t-dim)';
try {
const res = await hyprcrd.api('api/collab-buckets/upgrade', {
method: 'POST',
body: { bucket_id: localBucketId, members },
});
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
await pollOnce();
await loadCollabMeta();
done(true);
} catch (err) {
status.textContent = String(err?.message || err);
status.style.color = 'var(--t-danger)';
}
});
});
}
window._collabBucketsUpgradeDialog = upgradeBucketDialog;
// Expose helper so the core bucket context menu can route collab-bucket
// rename / color / delete through the plugin endpoints.
window._collabBucketsPatch = async function (collabUid, payload) {
return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'PATCH', body: payload });
};
window._collabBucketsDelete = async function (collabUid) {
return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'DELETE' });
};
// ── Phase 4: board-card lock decoration ───────────────────────────
/**
* Walk every rendered board card and add / remove a lock indicator if
* another user currently holds an edit lock on it. Idempotent — safe to
* call on every render / lock-poll.
*/
function decorateBoardLocks() {
const board = document.getElementById('board');
if (!board || !window.state?.cards) return;
// Build card_id -> card row lookup so we can resolve relay_uid
const byId = new Map();
for (const c of window.state.cards) byId.set(String(c.id), c);
const self = window._collabSelf || null;
for (const el of board.querySelectorAll('.board-card[data-id]')) {
const card = byId.get(el.dataset.id);
const lock = card && card.relay_uid ? collabLocks.get(card.relay_uid) : null;
// Ignore our own lock — no point showing the user their own editing state
const foreign = lock && lock.holder_username && lock.holder_username !== self;
el.classList.toggle('collab-locked', !!foreign);
let badge = el.querySelector('.collab-lock-badge');
if (foreign) {
if (!badge) {
badge = document.createElement('div');
badge.className = 'collab-lock-badge';
badge.innerHTML = '<i class="ph ph-lock-simple text-xs"></i>';
el.appendChild(badge);
}
badge.title = `Being edited by ${lock.holder_username}`;
} else if (badge) {
badge.remove();
}
}
}
// Re-decorate whenever the board re-renders (reuses the existing #board
// MutationObserver pattern).
function setupBoardObserver() {
const board = document.getElementById('board');
if (!board) return;
const obs = new MutationObserver(() => decorateBoardLocks());
obs.observe(board, { childList: true, subtree: false });
}
// ── Phase 4: heartbeat the lock while editing a collab card ───────
/**
* Resolve a card to its collab bucket_uid + relay_uid. Returns null for
* non-collab cards (which don't get a lock).
*/
function collabIdentityOf(card) {
if (!card || !card.bucket_id) return null;
const bucket = window.state?.buckets?.find(b => String(b.id) === String(card.bucket_id));
if (!bucket?.collab_uid) return null;
if (!card.relay_uid) return null; // not yet pushed → no lock
return { bucket_uid: bucket.collab_uid, card_uid: card.relay_uid };
}
async function acquireLock(bucket_uid, card_uid) {
try {
await hyprcrd.api('api/collab-locks', {
method: 'POST',
body: { bucket_uid, card_uid },
});
} catch { /* silent */ }
}
async function releaseLock(bucket_uid, card_uid) {
try {
await hyprcrd.api(
'api/collab-locks/' + encodeURIComponent(bucket_uid) + '/' + encodeURIComponent(card_uid),
{ method: 'DELETE' },
);
} catch { /* silent */ }
}
function startHeartbeat(bucket_uid, card_uid) {
stopHeartbeat();
activeLock = { bucket_uid, card_uid, timer: null };
acquireLock(bucket_uid, card_uid);
activeLock.timer = setInterval(() => acquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS);
}
function stopHeartbeat() {
if (!activeLock) return;
const { bucket_uid, card_uid, timer } = activeLock;
if (timer) clearInterval(timer);
releaseLock(bucket_uid, card_uid);
activeLock = null;
}
window.addEventListener('hyprcrd:editor-open', (e) => {
const card = e.detail?.card;
const id = collabIdentityOf(card);
if (id) startHeartbeat(id.bucket_uid, id.card_uid);
});
window.addEventListener('hyprcrd:editor-close', () => {
stopHeartbeat();
});
// Graceful release on tab close
window.addEventListener('beforeunload', () => {
if (activeLock) {
// Best-effort sync release; browsers may block regular fetch, so
// use sendBeacon when available.
try {
if (navigator.sendBeacon) {
const url = 'api/collab-locks/' + encodeURIComponent(activeLock.bucket_uid) + '/' + encodeURIComponent(activeLock.card_uid);
// sendBeacon only supports POST; use fetch keepalive as the
// DELETE fallback. Both are best-effort at unload time.
fetch(url, { method: 'DELETE', keepalive: true }).catch(() => {});
}
} catch { /* ignore */ }
}
});
// ── Init ──────────────────────────────────────────────────────────
async function init() {
// Wait for #bucket-bar to exist (core renders it on startup)
if (!document.getElementById('bucket-bar')) {
setTimeout(init, 100);
return;
}
setupBarObserver();
setupClickInterceptor();
setupBoardObserver();
await loadCollabMeta();
await pollOnce();
await loadCollabMeta();
decorateBucketBar();
decorateBoardLocks();
startPolling();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
hyprcrd.registerPlugin?.({
name: 'collab-buckets',
version: '1.1.0',
description: 'Collaborative buckets shared via the relay. First-class rows in the bucket bar with unread pulse/dot.',
});