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