/** * Sync page — device linking via autopass; create invite, pair with invite, show status. * Depends: core/utils.js ($), ui/modal.js (openModal, closeModal, showModalError), ui/toast.js (showToast) */ /** True after the user edits the device name field; blocks refresh from overwriting until save. */ let _syncDeviceNameDirty = false; function _markSyncDeviceNameDirty() { _syncDeviceNameDirty = true; } function _clearSyncDeviceNameDirty() { _syncDeviceNameDirty = false; } /** When a custom name exists, user can collapse to hero and reopen the editor. */ let _syncDeviceNameEditOpen = false; function _openSyncDeviceNameEdit() { _syncDeviceNameEditOpen = true; } function _closeSyncDeviceNameEdit() { _syncDeviceNameEditOpen = false; } /** Last successful getSyncStatus payload (for instant layout when opening "Change name"). */ let _lastSyncStatusForDevicePanel = null; function getDisplayNameMaxLen(response) { const n = response && typeof response.displayNameMaxLength === 'number' ? response.displayNameMaxLength : 64; return n > 0 ? n : 64; } function syncDevicePanelIsNamed(response) { return (response.localDisplayName || '').trim().length > 0; } function applySyncRuntimeBadges(runtimeKind) { const desktop = runtimeKind === 'desktop'; const text = desktop ? 'Desktop app' : 'Browser extension'; const cls = 'sync-runtime-badge ' + (desktop ? 'sync-runtime-badge--desktop' : 'sync-runtime-badge--browser'); const title = desktop ? 'Desktop app (Pear)' : 'Browser extension'; for (const id of ['syncRuntimeBadgeHero', 'syncRuntimeBadgeEdit']) { const el = $(id); if (!el) continue; el.textContent = text; el.className = cls; el.title = title; } } function syncRuntimeBadgeCellHtml(runtime) { if (runtime === 'desktop') { return 'Desktop app'; } if (runtime === 'browser-extension') { return 'Browser extension'; } return ''; } function sortLinkedDevices(devices) { const arr = devices.slice(); arr.sort((a, b) => { if (!!a.isCurrent !== !!b.isCurrent) return a.isCurrent ? -1 : 1; if (!!a.isMaster !== !!b.isMaster) return a.isMaster ? -1 : 1; const na = (a.name || a.id || '').toString(); const nb = (b.name || b.id || '').toString(); const c = na.localeCompare(nb); if (c !== 0) return c; return (a.id || '').localeCompare(b.id || ''); }); return arr; } const LINKED_DEVICES_FILTER_DEBOUNCE_MS = 250; let _linkedDevicesFilterDebounceTimer = null; /** Last successful getSyncStatus payload when linked (for debounced filter re-renders). */ let _lastLinkedDevicesContext = null; function getLinkedDeviceFilterQuery() { const el = $('syncLinkedDevicesFilter'); return el && typeof el.value === 'string' ? el.value : ''; } function computeLinkedDeviceLabel(d, response, index) { return d.name || (d.isCurrent ? (response.deviceName || 'This device') : ('Device ' + (index + 1))); } function linkedDeviceMatchesFilter(d, response, queryLower, index) { if (!queryLower) return true; const id = (d.id || '').toLowerCase(); const label = computeLinkedDeviceLabel(d, response, index).toLowerCase(); const rt = d.runtime === 'desktop' ? 'desktop app' : d.runtime === 'browser-extension' ? 'browser extension' : ''; return label.includes(queryLower) || id.includes(queryLower) || rt.includes(queryLower); } function buildLinkedDevicesTableBodyHtml(response) { const q = getLinkedDeviceFilterQuery().trim().toLowerCase(); const syncGroupId = response.syncGroupId || '—'; const rawDevices = Array.isArray(response.linkedDevices) ? response.linkedDevices : []; let rows = ''; if (rawDevices.length > 0) { const sorted = sortLinkedDevices(rawDevices); const withIndex = sorted.map((d, i) => ({ d, i })); const filtered = q ? withIndex.filter(({ d, i }) => linkedDeviceMatchesFilter(d, response, q, i)) : withIndex; if (filtered.length === 0 && q) { rows = 'No devices match your search'; } else { filtered.forEach(({ d, i }) => { const label = computeLinkedDeviceLabel(d, response, i); const masterBadge = d.isMaster ? ' MASTER' : ''; const youBadge = d.isCurrent ? ' This device' : ''; const rowClass = d.isCurrent ? ' class="sync-linked-row sync-linked-row--current"' : ' class="sync-linked-row"'; const typeCell = syncRuntimeBadgeCellHtml(d.runtime); rows += '' + escapeHtml(label) + masterBadge + youBadge + '' + escapeHtml(d.id || '—') + '' + typeCell + ''; }); } } else { const d = { id: response.deviceId, name: response.deviceName, isCurrent: true, isMaster: response.isMaster, runtime: response.runtimeKind }; if (q && !linkedDeviceMatchesFilter(d, response, q, 0)) { rows = 'No devices match your search'; } else { const label = computeLinkedDeviceLabel(d, response, 0); const masterBadge = d.isMaster ? ' MASTER' : ''; const youBadge = ' This device'; const rt = syncRuntimeBadgeCellHtml(response.runtimeKind); rows = '' + escapeHtml(label) + masterBadge + youBadge + '' + escapeHtml(response.deviceId || '—') + '' + rt + ''; } } rows += 'Sync group ' + escapeHtml(syncGroupId) + ''; return rows; } function renderLinkedDevicesTableBody() { const tbody = $('syncLinkedDevicesTable'); if (!tbody || !_lastLinkedDevicesContext) return; tbody.innerHTML = buildLinkedDevicesTableBodyHtml(_lastLinkedDevicesContext); } function scheduleLinkedDevicesFilterRender() { if (_linkedDevicesFilterDebounceTimer) clearTimeout(_linkedDevicesFilterDebounceTimer); _linkedDevicesFilterDebounceTimer = setTimeout(() => { _linkedDevicesFilterDebounceTimer = null; renderLinkedDevicesTableBody(); }, LINKED_DEVICES_FILTER_DEBOUNCE_MS); } function updateSyncDeviceNameCounter() { const input = $('syncDeviceNameInput'); const el = $('syncDeviceNameCounter'); if (!input || !el) return; const max = parseInt(input.getAttribute('maxlength'), 10) || 64; el.textContent = input.value.length + ' / ' + max; } function applySyncDeviceNamePanel(response) { if (!response || response.ok !== true) return; _lastSyncStatusForDevicePanel = response; const input = $('syncDeviceNameInput'); const hint = $('syncDeviceNameHint'); const hero = $('syncDeviceHero'); const editWrap = $('syncDeviceEditWrap'); const intro = $('syncDeviceIntro'); const display = $('syncDeviceNameDisplay'); const btnChange = $('btnChangeDeviceName'); const btnClear = $('btnClearDeviceName'); const card = $('syncDeviceCard'); const skipDeviceNameApply = _syncDeviceNameDirty || !!(input && document.activeElement === input); const maxLen = getDisplayNameMaxLen(response); if (input) { input.setAttribute('maxlength', String(maxLen)); } if (input && response.localDisplayName !== undefined && !skipDeviceNameApply) { input.value = response.localDisplayName; } updateSyncDeviceNameCounter(); if (response.runtimeKind) { applySyncRuntimeBadges(response.runtimeKind); } const isNamed = syncDevicePanelIsNamed(response); if (!isNamed) { _closeSyncDeviceNameEdit(); } const showEdit = !isNamed || _syncDeviceNameEditOpen; const showHero = isNamed && !_syncDeviceNameEditOpen; if (card) { card.classList.toggle('sync-device-card--named', isNamed); card.classList.toggle('sync-device-card--editing', showEdit); } if (intro) intro.hidden = !!isNamed; if (hero) hero.hidden = !showHero; if (editWrap) editWrap.hidden = !showEdit; if (btnChange) btnChange.hidden = !isNamed || _syncDeviceNameEditOpen; if (btnClear) btnClear.hidden = !(showEdit && isNamed); if (display && typeof response.effectiveDeviceName === 'string' && !skipDeviceNameApply) { display.textContent = response.effectiveDeviceName || ''; } if (hint && typeof response.effectiveDeviceName === 'string' && !skipDeviceNameApply) { hint.textContent = response.effectiveDeviceName ? ('Shown to other devices as: ' + response.effectiveDeviceName) : ''; } if (hint) hint.hidden = !showEdit; } function submitDeviceDisplayName() { const input = $('syncDeviceNameInput'); const name = input && typeof input.value === 'string' ? input.value.trim() : ''; chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'setDeviceDisplayName', payload: { displayName: name } } }, (res) => { if (chrome.runtime.lastError) { showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error'); return; } if (res && res.ok) { showToast('Device name saved', 'success'); _clearSyncDeviceNameDirty(); _closeSyncDeviceNameEdit(); updateSyncStatus(); } else { showToast(res && res.error ? res.error : 'Could not save name', 'error'); } } ); } function syncDeviceNameEscapeHandler() { if (!_syncDeviceNameEditOpen) return; _closeSyncDeviceNameEdit(); if (_lastSyncStatusForDevicePanel) { applySyncDeviceNamePanel(_lastSyncStatusForDevicePanel); } else { updateSyncStatus(); } } /** * Update the sync status UI from the native host. */ function updateSyncStatus() { chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'getSyncStatus' } }, (response) => { if (chrome.runtime.lastError) return; applySyncDeviceNamePanel(response); const dot = $('syncStatusDot'); const text = $('syncStatusText'); const lastSynced = $('syncLastSynced'); const btnCopy = $('btnCopyInvite'); const inviteDisplay = $('syncInviteDisplay'); if (!text) return; const linkDeviceForm = $('syncLinkDeviceForm'); if (!response || response.error || response.ok === false) { if (dot) dot.style.background = 'var(--text4)'; text.textContent = 'Not linked'; if (lastSynced) lastSynced.textContent = ''; if (btnCopy) btnCopy.style.display = 'none'; if (inviteDisplay) inviteDisplay.style.display = 'none'; if (linkDeviceForm) linkDeviceForm.style.display = 'flex'; return; } if (response.linked) { if (linkDeviceForm) linkDeviceForm.style.display = 'none'; if (dot) dot.style.background = 'var(--green)'; text.textContent = 'Linked'; if (response.lastSyncedAt) { if (lastSynced) lastSynced.textContent = 'Last synced ' + timeAgo(response.lastSyncedAt); } else if (lastSynced) lastSynced.textContent = ''; if (response.invite) { if (btnCopy) btnCopy.style.display = ''; if (inviteDisplay) { inviteDisplay.textContent = response.invite; inviteDisplay.style.display = 'block'; } } else { if (btnCopy) btnCopy.style.display = 'none'; if (inviteDisplay) inviteDisplay.style.display = 'none'; } const card = $('syncLinkedDevicesCard'); const tbody = $('syncLinkedDevicesTable'); const btnDelink = $('btnDelink'); const btnDisband = $('btnDisband'); if (card) card.style.display = 'block'; if (btnDelink) btnDelink.style.display = response.isMaster ? 'none' : ''; if (btnDisband) btnDisband.style.display = response.isMaster ? '' : 'none'; if (tbody) { _lastLinkedDevicesContext = response; tbody.innerHTML = buildLinkedDevicesTableBodyHtml(response); } } else { if (dot) dot.style.background = 'var(--text4)'; text.textContent = 'Not linked'; if (lastSynced) lastSynced.textContent = ''; if (btnCopy) btnCopy.style.display = 'none'; if (inviteDisplay) inviteDisplay.style.display = 'none'; if (linkDeviceForm) linkDeviceForm.style.display = 'flex'; const card = $('syncLinkedDevicesCard'); const tbody = $('syncLinkedDevicesTable'); _lastLinkedDevicesContext = null; if (card) card.style.display = 'none'; if (tbody) tbody.innerHTML = 'Not linked'; } } ); } /** * Attach event listeners for the Sync page. */ function setupSyncEvents() { const btnCreate = $('btnCreateSyncInvite'); const btnCopy = $('btnCopyInvite'); const btnPair = $('btnPairWithInvite'); const inviteInput = $('syncInviteInput'); const inviteDisplay = $('syncInviteDisplay'); $('syncLinkedDevicesFilter')?.addEventListener('input', () => { scheduleLinkedDevicesFilterRender(); }); $('syncDeviceNameInput')?.addEventListener('input', () => { _markSyncDeviceNameDirty(); updateSyncDeviceNameCounter(); }); $('syncDeviceNameInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submitDeviceDisplayName(); } else if (e.key === 'Escape') { syncDeviceNameEscapeHandler(); } }); $('btnChangeDeviceName')?.addEventListener('click', () => { _openSyncDeviceNameEdit(); if (_lastSyncStatusForDevicePanel) { applySyncDeviceNamePanel(_lastSyncStatusForDevicePanel); } requestAnimationFrame(() => $('syncDeviceNameInput')?.focus()); }); $('btnSaveDeviceName')?.addEventListener('click', () => { submitDeviceDisplayName(); }); $('btnClearDeviceName')?.addEventListener('click', () => { const input = $('syncDeviceNameInput'); if (input) input.value = ''; updateSyncDeviceNameCounter(); submitDeviceDisplayName(); }); btnCreate?.addEventListener('click', () => { if (btnCreate.disabled) return; btnCreate.disabled = true; btnCreate.textContent = 'Creating…'; chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'createSyncInvite' } }, (response) => { btnCreate.disabled = false; btnCreate.textContent = 'Create invite'; if (chrome.runtime.lastError) { showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error'); return; } if (response && response.ok && response.invite) { inviteDisplay.style.display = 'block'; inviteDisplay.textContent = response.invite; btnCopy.style.display = ''; showToast('Invite created. Share it with the other device.', 'success'); updateSyncStatus(); } else { showToast(response && response.error ? response.error : 'Failed to create invite', 'error'); } } ); }); btnCopy?.addEventListener('click', () => { if (!inviteDisplay || !inviteDisplay.textContent) return; navigator.clipboard.writeText(inviteDisplay.textContent).then(() => { showToast('Invite copied to clipboard', 'success'); }).catch(() => { showToast('Copy failed', 'error'); }); }); btnPair?.addEventListener('click', () => { const invite = inviteInput?.value?.trim(); if (!invite) { showToast('Paste an invite first', 'error'); return; } const errEl = $('linkDeviceError'); if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } openModal('modal-linkDevice'); }); $('linkDeviceConfirm')?.addEventListener('click', () => { const invite = inviteInput?.value?.trim(); const errEl = $('linkDeviceError'); if (!invite) { if (errEl) { errEl.textContent = 'Paste an invite in the field below first.'; errEl.style.display = 'block'; } return; } closeModal('modal-linkDevice'); if (btnPair) { btnPair.disabled = true; btnPair.textContent = 'Linking…'; } chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'pairWithInvite', payload: { invite } } }, (response) => { if (btnPair) { btnPair.disabled = false; btnPair.textContent = 'Link device'; } if (chrome.runtime.lastError) { showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error'); return; } if (response && response.ok) { showToast('Device linked. State has been synced.', 'success'); if (inviteInput) inviteInput.value = ''; updateSyncStatus(); if (typeof refresh === 'function') refresh(); } else { showToast(response && response.error ? response.error : 'Linking failed', 'error'); } } ); }); const btnDelink = $('btnDelink'); const btnDisband = $('btnDisband'); btnDelink?.addEventListener('click', () => { const errEl = $('delinkError'); if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } openModal('modal-delink'); }); $('delinkConfirm')?.addEventListener('click', () => { closeModal('modal-delink'); if (btnDelink) { btnDelink.disabled = true; btnDelink.textContent = 'Delinking…'; } chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'delink' } }, (response) => { if (btnDelink) { btnDelink.disabled = false; btnDelink.textContent = 'Delink'; } if (chrome.runtime.lastError) { showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error'); return; } if (response && response.ok) { showToast('Device delinked. State reset to defaults.', 'success'); updateSyncStatus(); if (typeof refresh === 'function') refresh(); } else { showToast(response && response.error ? response.error : 'Delink failed', 'error'); } } ); }); btnDisband?.addEventListener('click', () => { const errEl = $('disbandError'); if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } openModal('modal-disband'); }); $('disbandConfirm')?.addEventListener('click', () => { closeModal('modal-disband'); if (btnDisband) { btnDisband.disabled = true; btnDisband.textContent = 'Disbanding…'; } chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'disband' } }, (response) => { if (btnDisband) { btnDisband.disabled = false; btnDisband.textContent = 'Disband group'; } if (chrome.runtime.lastError) { showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error'); return; } if (response && response.ok) { showToast('Sync group disbanded. Other devices have been reset.', 'success'); updateSyncStatus(); if (typeof refresh === 'function') refresh(); } else { showToast(response && response.error ? response.error : 'Disband failed', 'error'); } } ); }); }