/** * 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 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 syncRuntimeBadgeHtml(runtime) { if (runtime === 'desktop') return ' Desktop app'; if (runtime === 'browser-extension') return ' Browser extension'; return ''; } 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 card = $('syncDeviceCard'); const skipDeviceNameApply = _syncDeviceNameDirty || !!(input && document.activeElement === input); if (input && response.localDisplayName !== undefined && !skipDeviceNameApply) { input.value = response.localDisplayName; } 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 (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; } /** * 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) { const syncGroupId = response.syncGroupId || '—'; const linkedDevices = Array.isArray(response.linkedDevices) ? response.linkedDevices : []; if (linkedDevices.length > 0) linkedDevices.sort((a, b) => (a.id || '').localeCompare(b.id || '')); let rows = ''; if (linkedDevices.length > 0) { linkedDevices.forEach((d, i) => { const label = d.name || (d.isCurrent ? (response.deviceName || 'This device') : ('Device ' + (i + 1))); const masterBadge = d.isMaster ? ' MASTER' : ''; const rt = syncRuntimeBadgeHtml(d.runtime); rows += '' + escapeHtml(label) + masterBadge + rt + '' + escapeHtml(d.id || '—') + ''; }); } else { const curName = response.deviceName || 'This device'; const masterBadge = response.isMaster ? ' MASTER' : ''; const rt = syncRuntimeBadgeHtml(response.runtimeKind); rows = '' + escapeHtml(curName) + masterBadge + rt + '' + escapeHtml(response.deviceId || '—') + ''; } rows += 'Sync group' + escapeHtml(syncGroupId) + ''; tbody.innerHTML = rows; } } 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'); 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'); $('syncDeviceNameInput')?.addEventListener('input', _markSyncDeviceNameDirty); $('btnChangeDeviceName')?.addEventListener('click', () => { _openSyncDeviceNameEdit(); if (_lastSyncStatusForDevicePanel) { applySyncDeviceNamePanel(_lastSyncStatusForDevicePanel); } requestAnimationFrame(() => $('syncDeviceNameInput')?.focus()); }); $('btnSaveDeviceName')?.addEventListener('click', () => { 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'); } } ); }); 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'); } } ); }); }