/**
* 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');
}
}
);
});
}