diff --git a/lib/community-sync.ts b/lib/community-sync.ts index ac11b937..1543f45d 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -880,6 +880,7 @@ export class CommunitySync extends EventTarget { */ #applyDocToDOM(): void { const shapes = this.#doc.shapes || {}; + const validIds = new Set(); for (const [id, shapeData] of Object.entries(shapes)) { const d = shapeData as Record; @@ -890,6 +891,7 @@ export class CommunitySync extends EventTarget { && (fb as Record)[this.#localDID]) { continue; } + validIds.add(id); this.#applyShapeToDOM(shapeData); // If forgotten by others (but not this user), emit state-changed for fade visual if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) { @@ -899,6 +901,13 @@ export class CommunitySync extends EventTarget { } } + // Prune stale DOM shapes that are deleted, forgotten, or no longer in the doc + for (const id of this.#shapes.keys()) { + if (!validIds.has(id)) { + this.#removeShapeFromDOM(id); + } + } + // Notify event bus if there are any events to process if (this.#doc.eventLog && this.#doc.eventLog.length > 0) { this.dispatchEvent(new CustomEvent("eventlog-changed")); diff --git a/server/shell.ts b/server/shell.ts index b84310d0..0cfd0ace 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -1069,11 +1069,10 @@ export function renderShell(opts: ShellOptions): string { })(); // Reconcile remote layer changes (shared by BroadcastChannel + Automerge) - function reconcileRemoteLayers(remoteLayers) { - // Guard: never let remote sync wipe all tabs when we have an active module. - // Empty remote layers indicate a CRDT initial state or sync race, not - // an intentional "close everything" action. - if (remoteLayers.length === 0 && currentModuleId) { + function reconcileRemoteLayers(remoteLayers, fromUserAction) { + // Guard: never let remote sync wipe all tabs when we have an active module, + // UNLESS this came from a deliberate user action (e.g. close-all via BroadcastChannel). + if (remoteLayers.length === 0 && currentModuleId && !fromUserAction) { // Keep local layers intact — remote has nothing useful tabBar.setLayers(layers); return; @@ -1105,7 +1104,7 @@ export function renderShell(opts: ShellOptions): string { if (e.data?.type !== 'tabs-sync') return; const remoteLayers = e.data.layers || []; for (const mid of (e.data.closed || [])) _closedModuleIds.add(mid); - reconcileRemoteLayers(remoteLayers); + reconcileRemoteLayers(remoteLayers, true); }; } @@ -1126,8 +1125,9 @@ export function renderShell(opts: ShellOptions): string { .then(r => r.ok ? r.json() : null) .then(data => { if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) { - // Server has nothing — push localStorage tabs up - saveTabs(); + // Server has no saved tabs. Only push if user has meaningful local tabs + // (not just the auto-added current module) + if (layers.length > 1) saveTabs(); return; } // Server-authoritative: adopt server tabs exactly. @@ -1522,23 +1522,22 @@ export function renderShell(opts: ShellOptions): string { const localActiveId = 'layer-' + currentModuleId; - // Merge: Automerge layers win if they exist, otherwise seed from localStorage + // User's saved tabs are authoritative — sync Automerge to match. const remoteLayers = sync.getLayers(); - if (remoteLayers.length > 0) { - // Ensure current module is also in the Automerge set - if (!remoteLayers.find(l => l.moduleId === currentModuleId)) { - const newLayer = makeLayer(currentModuleId, remoteLayers.length); - sync.addLayer(newLayer); + + // Remove Automerge-only layers that the user doesn't have open + for (const rl of remoteLayers) { + if (!layers.find(l => l.moduleId === rl.moduleId)) { + sync.removeLayer(rl.id); } - layers = deduplicateLayers(sync.getLayers()); - tabBar.setLayers(layers); - tabBar.setFlows(sync.getFlows()); - } else { - // First connection: push all localStorage tabs into Automerge - for (const l of layers) { + } + // Add user's tabs that aren't in Automerge yet + for (const l of layers) { + if (!remoteLayers.find(rl => rl.moduleId === l.moduleId)) { sync.addLayer(l); } } + tabBar.setFlows(sync.getFlows()); // Active tab stays local — always matches the URL tabBar.setAttribute('active', localActiveId); @@ -1575,7 +1574,7 @@ export function renderShell(opts: ShellOptions): string { // Never touch the active tab: it's managed locally by TabCache // and the tab-bar component via layer-switch events. sync.addEventListener('change', () => { - reconcileRemoteLayers(sync.getLayers()); + reconcileRemoteLayers(sync.getLayers(), false); tabBar.setFlows(sync.getFlows()); const viewMode = sync.doc.layerViewMode; if (viewMode) tabBar.setAttribute('view-mode', viewMode); diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index b64ac89e..bc9320db 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -470,9 +470,10 @@ export class RStackIdentity extends HTMLElement { const cached = localStorage.getItem(CACHE_KEY); if (cached) { try { - const { ts, ok } = JSON.parse(cached); - if (Date.now() - ts < 30 * 60 * 1000) { - if (!ok) this.#showRecoveryDot(); + const c = JSON.parse(cached); + if (Date.now() - c.ts < 30 * 60 * 1000) { + if (!c.socialRecovery) this.#showRecoveryDot(); + if (!c.email || !c.multiDevice || !c.socialRecovery) this.#showAccountDot(); return; } } catch { /* stale cache */ } @@ -484,9 +485,9 @@ export class RStackIdentity extends HTMLElement { }); if (!res.ok) return; const status = await res.json(); - const recoveryOk = status.socialRecovery === true; - localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), ok: recoveryOk })); - if (!recoveryOk) this.#showRecoveryDot(); + localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), email: !!status.email, multiDevice: !!status.multiDevice, socialRecovery: !!status.socialRecovery })); + if (!status.socialRecovery) this.#showRecoveryDot(); + if (!status.email || !status.multiDevice || !status.socialRecovery) this.#showAccountDot(); } catch { /* offline */ } } @@ -499,6 +500,14 @@ export class RStackIdentity extends HTMLElement { wrap.appendChild(dot); } + #showAccountDot() { + const btn = this.#shadow.querySelector('[data-action="my-account"]'); + if (!btn || btn.querySelector(".acct-alert-dot")) return; + const dot = document.createElement("span"); + dot.className = "acct-alert-dot"; + btn.appendChild(dot); + } + async #checkDeviceNudge() { const session = getSession(); if (!session?.accessToken) return; @@ -1360,10 +1369,6 @@ export class RStackIdentity extends HTMLElement { let devicesLoaded = false; let devicesLoading = false; - let addresses: { id: string; street: string; city: string; state: string; zip: string; country: string }[] = []; - let addressesLoaded = false; - let addressesLoading = false; - // Connections data let connectionsLoaded = false; let connectionsLoading = false; @@ -1418,7 +1423,6 @@ export class RStackIdentity extends HTMLElement { ${renderEmailSection()} ${renderDeviceSection()} ${renderRecoverySection()} - ${renderAddressSection()}