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