Files
hyprcrd/public/plugins/collab-buckets/collab-buckets.js
T
thandClaude Opus 4.6 26f95ba079 collab per-card unread, detail stale banner, image sync race fix
- per-card unread dot on board (collab-buckets): backend tracks
  last_seen_at per card, poll exposes unread_cards, frontend decorates
  matching board cards; cleared on any open path via detail.opened hook
- detail.opened + cards.reloaded hooks on the hyprcrd event bus
- stale banner in detail view with manual Reload when the currently
  viewed card got updated by a peer — no content swap under the user
- collab image sync: push attachments BEFORE card (race fix) + pull-side
  self-heal scans every collab card for missing uploads each poll
- relay user deletion now purges chat_messages sent by deleted user so
  ghost threads stop appearing for their peers
- calendar header nav buttons styled as square bordered buttons that
  honor --ui-radius-button; title bar widened to 280px so font variants
  don't wrap
- .toolbar-btn now uses var(--ui-radius-button) instead of hardcoded 3px

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:39:32 +02:00

999 lines
45 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();
// card_id (number) -> true for cards with foreign edits we haven't opened yet
let unreadCards = new Set();
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 */ }
}
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;
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 = `
<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();
}
}
}
/**
* Walk every rendered board card and add / remove the "red dot" unread
* indicator for cards with foreign edits we haven't opened yet.
* Idempotent — safe to re-run on every board re-render.
*/
function decorateBoardUnread() {
const board = document.getElementById('board');
if (!board) return;
for (const el of board.querySelectorAll('.board-card[data-id]')) {
const cid = parseInt(el.dataset.id);
const isUnread = unreadCards.has(cid);
el.classList.toggle('collab-card-unread', isUnread);
let dot = el.querySelector('.collab-card-dot');
if (isUnread && !dot) {
dot = document.createElement('span');
dot.className = 'collab-card-dot';
el.appendChild(dot);
} else if (!isUnread && dot) {
dot.remove();
}
}
}
// Clear a card's unread state locally + on the backend. Called from
// - board click (piggy-backing on the natural open-card interaction)
// - detail.opened hook (when the detail view opens via any path:
// keyboard shortcut, related-card link, backlink, search result, etc.)
function clearCardUnread(cid) {
cid = parseInt(cid);
if (!cid || !unreadCards.has(cid)) return;
unreadCards.delete(cid);
const el = document.querySelector(`#board .board-card[data-id="${cid}"]`);
if (el) {
el.classList.remove('collab-card-unread');
el.querySelector('.collab-card-dot')?.remove();
}
hyprcrd.api('api/collab-buckets/mark-card-seen', {
method: 'POST', body: { card_id: cid },
}).catch(() => {});
}
function setupCardClickInterceptor() {
const board = document.getElementById('board');
if (board) {
board.addEventListener('click', (e) => {
const el = e.target.closest('.board-card[data-id]');
if (el) clearCardUnread(el.dataset.id);
});
}
// Detail view has its own open path (keyboard shortcuts, backlinks,
// related-card navigation) that doesn't route through a board-card
// click. Listen for the explicit detail.opened hook so every open
// method clears the dot.
hyprcrd.hooks?.on?.('detail.opened', (data) => clearCardUnread(data?.id));
}
// 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();
decorateBoardUnread();
});
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();
setupCardClickInterceptor();
setupBoardObserver();
await loadCollabMeta();
await pollOnce();
await loadCollabMeta();
decorateBucketBar();
decorateBoardLocks();
decorateBoardUnread();
startPolling();
}
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
// boot, and firing POST /api/collab-buckets/poll before that racing lookup
// resolves returns 403. If the token is already in the util state when we
// load (e.g. fast reload, cached session), kick off init straight away.
function bootWhenCsrfReady() {
if (hyprcrd?.util?.csrf?.()) { init(); return; }
hyprcrd?.hooks?.on?.('csrf.ready', () => init());
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootWhenCsrfReady, { once: true });
} else {
bootWhenCsrfReady();
}
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.',
});