Files
hyprcrd/public/plugins/collab-buckets/collab-buckets.js
thandClaude Opus 4.6 f6cf7b4358 collab-buckets: exclusive locks, server-enforced, with editor takeover
The previous lock implementation was advisory only — the relay's lock
table PK was (bucket_uid, card_uid, holder_id), so multiple users could
"hold" the same card simultaneously and the plugin only used the locks
to draw a small badge. Concurrent writers raced through LWW, and an
unsaved editor on User A could be silently overwritten when User B
saved (the symptom: cards appearing to "disappear" from User A's view
once their version was clobbered by an LWW pull).

Now an exclusive, server-enforced lock:

* relay schema: collab_card_locks PK is (bucket_uid, card_uid). One
  lock per card. Migration drops the old 3-column-PK table on the
  next bootstrap (locks are 60s ephemeral, no data to preserve).
* /api/collab-locks POST: returns 409 + holder info when another
  tenant already holds it. Same holder refreshes the TTL.
* /api/collab-cards POST/DELETE: refuse with 423 + locked_by when a
  foreign tenant holds the lock. The plugin's local edit is preserved
  — the push just stays queued and retries on the next poll until
  the lock expires or is released. Tombstones are gated the same way
  so a card can't be deleted under the holder's editor.
* Plugin push phase: tracks blocked count + holder names, surfaces
  them in the poll response so the frontend can toast the user.

Frontend lock-on-first-keystroke flow:

* The lock is no longer acquired on editor-open — opening for read
  doesn't lock anything. On the FIRST input event in #inp-title or
  #editor-body the plugin tries to acquire. On success, heartbeat
  every 30 s; on 409, immediately enter editor takeover.
* Takeover mode: title + body inputs disabled, all three save
  buttons get a capture-phase click blocker (stopImmediatePropagation
  to defeat the editor's own listener), a window-level capture-phase
  keydown blocker swallows Ctrl/Cmd+S (app.js binds save directly to
  the shortcut, bypassing the buttons), and a banner inside the
  modal explains the situation. The user can still copy their
  unsaved text out before closing.
* If a foreign lock arrives via the regular poll while the editor is
  already open on that card, the same takeover engages mid-session.
* Opening the editor on a card whose lock is *already* known to be
  foreign starts in takeover mode without arming the input listeners
  at all.
* Self-detection: poll response now carries `self` (the relay
  username), cached as window._collabSelf, so we don't take ourselves
  over and the board decoration ignores our own lock.

Visual:

* Board cards held by a foreign lock now get a frosted overlay +
  centered lock icon + holder name (replaces the small corner badge).
* Editor takeover banner uses the danger accent. Save buttons get a
  .collab-save-blocked dim style.
* New `.collab-lock-toast` variant of the existing conflict toast,
  used for both "your push is queued because X is editing" and
  "this card just got locked by X" notifications.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:36:57 +02:00

915 lines
41 KiB
JavaScript

// ── 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 (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, '&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;
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.`);
}
// 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 */ }
}
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';
el.appendChild(badge);
}
badge.innerHTML = `
<i class="ph ph-lock-simple-fill"></i>
<span>${escapeHtml(lock.holder_username)}</span>
`;
badge.title = `Locked 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 };
}
/** Acquire (or refresh) the exclusive lock. Returns:
* { ok: true } on success
* { ok: false, locked_by: '…' } when another tenant already holds it (409)
* { ok: false } on transport / unknown error */
async function tryAcquireLock(bucket_uid, card_uid) {
try {
const res = await hyprcrd.api('api/collab-locks', {
method: 'POST',
body: { bucket_uid, card_uid },
});
if (res?.ok) return { ok: true };
if (res?.locked_by) return { ok: false, locked_by: res.locked_by };
return { ok: false };
} catch {
return { ok: false };
}
}
async function releaseLock(bucket_uid, card_uid) {
try {
await hyprcrd.api(
'api/collab-locks/' + encodeURIComponent(bucket_uid) + '/' + encodeURIComponent(card_uid),
{ method: 'DELETE' },
);
} catch { /* silent */ }
}
/** First-keystroke lock: sets up one-shot input listeners on the editor
* inputs. The first input event tries to acquire the exclusive lock; on
* success we start the heartbeat, on failure we slap the editor into
* takeover mode. */
function armFirstKeystrokeLock() {
if (!editorSession) return;
const inputs = [document.getElementById('inp-title'), document.getElementById('editor-body')].filter(Boolean);
let fired = false;
const onInput = async () => {
if (fired) return;
fired = true;
// Drop the listeners — heartbeat or takeover takes over from here
for (const el of inputs) el.removeEventListener('input', onInput);
if (!editorSession) return;
const { bucket_uid, card_uid } = editorSession;
const res = await tryAcquireLock(bucket_uid, card_uid);
if (!editorSession) return; // editor closed during the await
if (res.ok) {
editorSession.acquired = true;
editorSession.hbTimer = setInterval(() => tryAcquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS);
} else {
enterEditorTakeover(res.locked_by || 'someone');
}
};
for (const el of inputs) el.addEventListener('input', onInput);
editorSession.inputCleanup = () => {
for (const el of inputs) el.removeEventListener('input', onInput);
};
}
/** Switch the editor to read-only takeover mode. The user can still see /
* copy their text but the inputs are disabled and save is blocked. */
function enterEditorTakeover(holder) {
if (!editorSession || editorSession.takeover) return;
editorSession.takeover = true;
const modal = document.getElementById('modal');
const title = document.getElementById('inp-title');
const body = document.getElementById('editor-body');
if (title) title.disabled = true;
if (body) body.disabled = true;
modal?.classList.add('collab-readonly-modal');
// Block save buttons by capturing clicks before the editor handler.
// stopImmediatePropagation also blocks any sibling listeners registered
// on the same element (the editor's own click handler) and skips both
// target + bubble phases for ancestors.
const saveIds = ['modal-save', 'modal-save-view', 'modal-save-close'];
const blockers = saveIds.map(id => {
const btn = document.getElementById(id);
if (!btn) return null;
const handler = (e) => {
e.stopImmediatePropagation();
e.preventDefault();
};
btn.addEventListener('click', handler, true); // capture phase
btn.setAttribute('disabled', 'disabled');
btn.classList.add('collab-save-blocked');
return { btn, handler };
}).filter(Boolean);
editorSession.saveBlockers = blockers;
// Also intercept Ctrl/Cmd+S — app.js binds this directly to saveCard/
// saveOnly without going through the buttons we just disabled.
const keyBlocker = (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
e.stopImmediatePropagation();
}
};
window.addEventListener('keydown', keyBlocker, true);
editorSession.keyBlocker = keyBlocker;
// Banner inside the modal explaining the situation
let banner = document.getElementById('collab-takeover-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'collab-takeover-banner';
banner.className = 'collab-takeover-banner';
const inner = document.querySelector('#modal .modal-inner') || document.querySelector('#modal > div') || modal;
inner?.prepend(banner);
}
banner.innerHTML = `
<i class="ph ph-lock-simple-fill"></i>
<span><b>Locked by ${escapeHtml(holder)}.</b> This card was locked by another user while you were typing. Copy your text out — saving is disabled.</span>
`;
// Also show a one-off toast so the user can't miss it
showLockToast(holder);
}
function exitEditorTakeover() {
const modal = document.getElementById('modal');
const title = document.getElementById('inp-title');
const body = document.getElementById('editor-body');
if (title) title.disabled = false;
if (body) body.disabled = false;
modal?.classList.remove('collab-readonly-modal');
document.getElementById('collab-takeover-banner')?.remove();
if (editorSession?.saveBlockers) {
for (const { btn, handler } of editorSession.saveBlockers) {
btn.removeEventListener('click', handler, true);
btn.removeAttribute('disabled');
btn.classList.remove('collab-save-blocked');
}
editorSession.saveBlockers = null;
}
if (editorSession?.keyBlocker) {
window.removeEventListener('keydown', editorSession.keyBlocker, true);
editorSession.keyBlocker = null;
}
}
function showLockToast(holder) {
const existing = document.getElementById('collab-lock-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'collab-lock-toast';
toast.className = 'collab-conflict-toast collab-lock-toast';
toast.innerHTML = `
<i class="ph ph-lock-simple-fill"></i>
<span><b>Locked by ${escapeHtml(holder)}.</b> Copy your text — saving is blocked.</span>
<button class="collab-conflict-close" aria-label="dismiss">✕</button>
`;
toast.querySelector('.collab-conflict-close').addEventListener('click', () => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 400);
});
document.body.appendChild(toast);
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 8000);
}
function showBlockedToast(by) {
const existing = document.getElementById('collab-blocked-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'collab-blocked-toast';
toast.className = 'collab-conflict-toast collab-lock-toast';
const who = by.length ? by.join(', ') : 'another user';
toast.innerHTML = `
<i class="ph ph-lock-simple-fill"></i>
<span>Your edit is queued — <b>${escapeHtml(who)}</b> is currently editing. It'll sync when they're done.</span>
<button class="collab-conflict-close" aria-label="dismiss">✕</button>
`;
toast.querySelector('.collab-conflict-close').addEventListener('click', () => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 400);
});
document.body.appendChild(toast);
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
}
/** Tear down everything we own for the active editor session. Called both
* on editor-close and on plugin reload. */
function endEditorSession() {
if (!editorSession) return;
const { hbTimer, inputCleanup, acquired, bucket_uid, card_uid } = editorSession;
if (hbTimer) clearInterval(hbTimer);
if (typeof inputCleanup === 'function') inputCleanup();
if (acquired) releaseLock(bucket_uid, card_uid);
exitEditorTakeover();
editorSession = null;
}
window.addEventListener('hyprcrd:editor-open', (e) => {
endEditorSession();
const card = e.detail?.card;
const id = collabIdentityOf(card);
if (!id) return;
editorSession = {
bucket_uid: id.bucket_uid,
card_uid: id.card_uid,
cardId: card?.id,
hbTimer: null,
inputCleanup: null,
acquired: false,
takeover: false,
saveBlockers: null,
};
// If a foreign lock is already known from the last poll, take over
// immediately — no point letting the user type into a doomed editor.
const existing = collabLocks.get(id.card_uid);
const self = window._collabSelf || null;
if (existing && existing.holder_username && existing.holder_username !== self) {
enterEditorTakeover(existing.holder_username);
return;
}
armFirstKeystrokeLock();
});
window.addEventListener('hyprcrd:editor-close', () => {
endEditorSession();
});
// Graceful release on tab close
window.addEventListener('beforeunload', () => {
if (editorSession?.acquired) {
try {
const url = 'api/collab-locks/' + encodeURIComponent(editorSession.bucket_uid) + '/' + encodeURIComponent(editorSession.card_uid);
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.',
});