Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m23s
Details
CI/CD / deploy (push) Successful in 2m23s
Details
This commit is contained in:
commit
8e69a34d35
|
|
@ -880,6 +880,7 @@ export class CommunitySync extends EventTarget {
|
||||||
*/
|
*/
|
||||||
#applyDocToDOM(): void {
|
#applyDocToDOM(): void {
|
||||||
const shapes = this.#doc.shapes || {};
|
const shapes = this.#doc.shapes || {};
|
||||||
|
const validIds = new Set<string>();
|
||||||
|
|
||||||
for (const [id, shapeData] of Object.entries(shapes)) {
|
for (const [id, shapeData] of Object.entries(shapes)) {
|
||||||
const d = shapeData as Record<string, unknown>;
|
const d = shapeData as Record<string, unknown>;
|
||||||
|
|
@ -890,6 +891,7 @@ export class CommunitySync extends EventTarget {
|
||||||
&& (fb as Record<string, number>)[this.#localDID]) {
|
&& (fb as Record<string, number>)[this.#localDID]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
validIds.add(id);
|
||||||
this.#applyShapeToDOM(shapeData);
|
this.#applyShapeToDOM(shapeData);
|
||||||
// If forgotten by others (but not this user), emit state-changed for fade visual
|
// If forgotten by others (but not this user), emit state-changed for fade visual
|
||||||
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) {
|
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
|
// Notify event bus if there are any events to process
|
||||||
if (this.#doc.eventLog && this.#doc.eventLog.length > 0) {
|
if (this.#doc.eventLog && this.#doc.eventLog.length > 0) {
|
||||||
this.dispatchEvent(new CustomEvent("eventlog-changed"));
|
this.dispatchEvent(new CustomEvent("eventlog-changed"));
|
||||||
|
|
|
||||||
|
|
@ -1069,11 +1069,10 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Reconcile remote layer changes (shared by BroadcastChannel + Automerge)
|
// Reconcile remote layer changes (shared by BroadcastChannel + Automerge)
|
||||||
function reconcileRemoteLayers(remoteLayers) {
|
function reconcileRemoteLayers(remoteLayers, fromUserAction) {
|
||||||
// Guard: never let remote sync wipe all tabs when we have an active module.
|
// 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
|
// UNLESS this came from a deliberate user action (e.g. close-all via BroadcastChannel).
|
||||||
// an intentional "close everything" action.
|
if (remoteLayers.length === 0 && currentModuleId && !fromUserAction) {
|
||||||
if (remoteLayers.length === 0 && currentModuleId) {
|
|
||||||
// Keep local layers intact — remote has nothing useful
|
// Keep local layers intact — remote has nothing useful
|
||||||
tabBar.setLayers(layers);
|
tabBar.setLayers(layers);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1105,7 +1104,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
if (e.data?.type !== 'tabs-sync') return;
|
if (e.data?.type !== 'tabs-sync') return;
|
||||||
const remoteLayers = e.data.layers || [];
|
const remoteLayers = e.data.layers || [];
|
||||||
for (const mid of (e.data.closed || [])) _closedModuleIds.add(mid);
|
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(r => r.ok ? r.json() : null)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) {
|
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) {
|
||||||
// Server has nothing — push localStorage tabs up
|
// Server has no saved tabs. Only push if user has meaningful local tabs
|
||||||
saveTabs();
|
// (not just the auto-added current module)
|
||||||
|
if (layers.length > 1) saveTabs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Server-authoritative: adopt server tabs exactly.
|
// Server-authoritative: adopt server tabs exactly.
|
||||||
|
|
@ -1522,23 +1522,22 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
|
|
||||||
const localActiveId = 'layer-' + currentModuleId;
|
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();
|
const remoteLayers = sync.getLayers();
|
||||||
if (remoteLayers.length > 0) {
|
|
||||||
// Ensure current module is also in the Automerge set
|
// Remove Automerge-only layers that the user doesn't have open
|
||||||
if (!remoteLayers.find(l => l.moduleId === currentModuleId)) {
|
for (const rl of remoteLayers) {
|
||||||
const newLayer = makeLayer(currentModuleId, remoteLayers.length);
|
if (!layers.find(l => l.moduleId === rl.moduleId)) {
|
||||||
sync.addLayer(newLayer);
|
sync.removeLayer(rl.id);
|
||||||
}
|
}
|
||||||
layers = deduplicateLayers(sync.getLayers());
|
}
|
||||||
tabBar.setLayers(layers);
|
// Add user's tabs that aren't in Automerge yet
|
||||||
tabBar.setFlows(sync.getFlows());
|
for (const l of layers) {
|
||||||
} else {
|
if (!remoteLayers.find(rl => rl.moduleId === l.moduleId)) {
|
||||||
// First connection: push all localStorage tabs into Automerge
|
|
||||||
for (const l of layers) {
|
|
||||||
sync.addLayer(l);
|
sync.addLayer(l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tabBar.setFlows(sync.getFlows());
|
||||||
|
|
||||||
// Active tab stays local — always matches the URL
|
// Active tab stays local — always matches the URL
|
||||||
tabBar.setAttribute('active', localActiveId);
|
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
|
// Never touch the active tab: it's managed locally by TabCache
|
||||||
// and the tab-bar component via layer-switch events.
|
// and the tab-bar component via layer-switch events.
|
||||||
sync.addEventListener('change', () => {
|
sync.addEventListener('change', () => {
|
||||||
reconcileRemoteLayers(sync.getLayers());
|
reconcileRemoteLayers(sync.getLayers(), false);
|
||||||
tabBar.setFlows(sync.getFlows());
|
tabBar.setFlows(sync.getFlows());
|
||||||
const viewMode = sync.doc.layerViewMode;
|
const viewMode = sync.doc.layerViewMode;
|
||||||
if (viewMode) tabBar.setAttribute('view-mode', viewMode);
|
if (viewMode) tabBar.setAttribute('view-mode', viewMode);
|
||||||
|
|
|
||||||
|
|
@ -470,9 +470,10 @@ export class RStackIdentity extends HTMLElement {
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
const { ts, ok } = JSON.parse(cached);
|
const c = JSON.parse(cached);
|
||||||
if (Date.now() - ts < 30 * 60 * 1000) {
|
if (Date.now() - c.ts < 30 * 60 * 1000) {
|
||||||
if (!ok) this.#showRecoveryDot();
|
if (!c.socialRecovery) this.#showRecoveryDot();
|
||||||
|
if (!c.email || !c.multiDevice || !c.socialRecovery) this.#showAccountDot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* stale cache */ }
|
} catch { /* stale cache */ }
|
||||||
|
|
@ -484,9 +485,9 @@ export class RStackIdentity extends HTMLElement {
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const status = await res.json();
|
const status = await res.json();
|
||||||
const recoveryOk = status.socialRecovery === true;
|
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), email: !!status.email, multiDevice: !!status.multiDevice, socialRecovery: !!status.socialRecovery }));
|
||||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), ok: recoveryOk }));
|
if (!status.socialRecovery) this.#showRecoveryDot();
|
||||||
if (!recoveryOk) this.#showRecoveryDot();
|
if (!status.email || !status.multiDevice || !status.socialRecovery) this.#showAccountDot();
|
||||||
} catch { /* offline */ }
|
} catch { /* offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,6 +500,14 @@ export class RStackIdentity extends HTMLElement {
|
||||||
wrap.appendChild(dot);
|
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() {
|
async #checkDeviceNudge() {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
if (!session?.accessToken) return;
|
if (!session?.accessToken) return;
|
||||||
|
|
@ -1360,10 +1369,6 @@ export class RStackIdentity extends HTMLElement {
|
||||||
let devicesLoaded = false;
|
let devicesLoaded = false;
|
||||||
let devicesLoading = 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
|
// Connections data
|
||||||
let connectionsLoaded = false;
|
let connectionsLoaded = false;
|
||||||
let connectionsLoading = false;
|
let connectionsLoading = false;
|
||||||
|
|
@ -1418,7 +1423,6 @@ export class RStackIdentity extends HTMLElement {
|
||||||
${renderEmailSection()}
|
${renderEmailSection()}
|
||||||
${renderDeviceSection()}
|
${renderDeviceSection()}
|
||||||
${renderRecoverySection()}
|
${renderRecoverySection()}
|
||||||
${renderAddressSection()}
|
|
||||||
|
|
||||||
<div class="account-section account-section--inline${!backupEnabled ? " section--warning" : ""}">
|
<div class="account-section account-section--inline${!backupEnabled ? " section--warning" : ""}">
|
||||||
<div class="account-section-header">
|
<div class="account-section-header">
|
||||||
|
|
@ -1714,52 +1718,7 @@ export class RStackIdentity extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAddressSection = () => {
|
|
||||||
const isOpen = openSection === "address";
|
|
||||||
let body = "";
|
|
||||||
if (isOpen) {
|
|
||||||
if (addressesLoading) {
|
|
||||||
body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading addresses...</div></div>`;
|
|
||||||
} else {
|
|
||||||
const listHTML = addresses.length > 0
|
|
||||||
? `<div class="contact-list">${addresses.map(a => `
|
|
||||||
<div class="contact-item">
|
|
||||||
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1;font-size:0.85rem">
|
|
||||||
<span>${a.street.replace(/</g, "<")}</span>
|
|
||||||
<span style="color:var(--rs-text-secondary)">${a.city.replace(/</g, "<")}, ${a.state.replace(/</g, "<")} ${a.zip.replace(/</g, "<")} ${a.country.replace(/</g, "<")}</span>
|
|
||||||
</div>
|
|
||||||
<button class="contact-remove" data-remove-address="${a.id}">×</button>
|
|
||||||
</div>
|
|
||||||
`).join("")}</div>` : "";
|
|
||||||
|
|
||||||
body = `
|
|
||||||
<div class="account-section-body">
|
|
||||||
<div class="address-form">
|
|
||||||
<input class="input" id="acct-street" type="text" placeholder="Street address" />
|
|
||||||
<div class="address-row">
|
|
||||||
<input class="input" id="acct-city" type="text" placeholder="City" />
|
|
||||||
<input class="input" id="acct-state" type="text" placeholder="State" />
|
|
||||||
</div>
|
|
||||||
<div class="address-row">
|
|
||||||
<input class="input" id="acct-zip" type="text" placeholder="ZIP / Postal code" />
|
|
||||||
<input class="input" id="acct-country" type="text" placeholder="Country" />
|
|
||||||
</div>
|
|
||||||
<button class="btn btn--primary" data-action="save-address" style="align-self:flex-start">Save Address</button>
|
|
||||||
</div>
|
|
||||||
${listHTML}
|
|
||||||
<div class="error" id="address-error"></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `
|
|
||||||
<div class="account-section${isOpen ? " open" : ""}">
|
|
||||||
<div class="account-section-header" data-section="address">
|
|
||||||
<span>🏠 Postal Address</span>
|
|
||||||
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
|
|
||||||
</div>
|
|
||||||
${body}
|
|
||||||
</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderShortcutsSection = () => {
|
const renderShortcutsSection = () => {
|
||||||
const isOpen = openSection === "shortcuts";
|
const isOpen = openSection === "shortcuts";
|
||||||
|
|
@ -1944,31 +1903,6 @@ export class RStackIdentity extends HTMLElement {
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAddresses = async () => {
|
|
||||||
if (addressesLoaded || addressesLoading) return;
|
|
||||||
addressesLoading = true;
|
|
||||||
render();
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${ENCRYPTID_URL}/api/user/addresses`, {
|
|
||||||
headers: { Authorization: `Bearer ${getAccessToken()}` },
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
addresses = (data.addresses || []).map((a: any) => {
|
|
||||||
try {
|
|
||||||
const decoded = JSON.parse(atob(a.ciphertext));
|
|
||||||
return { id: a.id, ...decoded };
|
|
||||||
} catch {
|
|
||||||
return { id: a.id, street: "", city: "", state: "", zip: "", country: "" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* offline */ }
|
|
||||||
addressesLoaded = true;
|
|
||||||
addressesLoading = false;
|
|
||||||
render();
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
if (devicesLoaded || devicesLoading) return;
|
if (devicesLoaded || devicesLoading) return;
|
||||||
devicesLoading = true;
|
devicesLoading = true;
|
||||||
|
|
@ -2003,7 +1937,6 @@ export class RStackIdentity extends HTMLElement {
|
||||||
const section = (el as HTMLElement).dataset.section!;
|
const section = (el as HTMLElement).dataset.section!;
|
||||||
openSection = openSection === section ? null : section;
|
openSection = openSection === section ? null : section;
|
||||||
if (openSection === "recovery") loadGuardians();
|
if (openSection === "recovery") loadGuardians();
|
||||||
if (openSection === "address") loadAddresses();
|
|
||||||
if (openSection === "device") loadDevices();
|
if (openSection === "device") loadDevices();
|
||||||
if (openSection === "connections") loadConnections();
|
if (openSection === "connections") loadConnections();
|
||||||
render();
|
render();
|
||||||
|
|
@ -2261,58 +2194,6 @@ export class RStackIdentity extends HTMLElement {
|
||||||
showWalkthrough = false; walkthroughStep = 0; render();
|
showWalkthrough = false; walkthroughStep = 0; render();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Address: save
|
|
||||||
overlay.querySelector('[data-action="save-address"]')?.addEventListener("click", async () => {
|
|
||||||
const street = (overlay.querySelector("#acct-street") as HTMLInputElement)?.value.trim() || "";
|
|
||||||
const city = (overlay.querySelector("#acct-city") as HTMLInputElement)?.value.trim() || "";
|
|
||||||
const state = (overlay.querySelector("#acct-state") as HTMLInputElement)?.value.trim() || "";
|
|
||||||
const zip = (overlay.querySelector("#acct-zip") as HTMLInputElement)?.value.trim() || "";
|
|
||||||
const country = (overlay.querySelector("#acct-country") as HTMLInputElement)?.value.trim() || "";
|
|
||||||
const err = overlay.querySelector("#address-error") as HTMLElement;
|
|
||||||
const btn = overlay.querySelector('[data-action="save-address"]') as HTMLButtonElement;
|
|
||||||
|
|
||||||
if (!street || !city) { err.textContent = "Street and city are required."; return; }
|
|
||||||
err.textContent = "";
|
|
||||||
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Saving...';
|
|
||||||
|
|
||||||
const payload = { street, city, state, zip, country };
|
|
||||||
const ciphertext = btoa(JSON.stringify(payload));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${ENCRYPTID_URL}/api/user/addresses`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
|
|
||||||
body: JSON.stringify({ ciphertext }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to save address");
|
|
||||||
addresses.push({ id: data.id || data.address?.id || String(Date.now()), ...payload });
|
|
||||||
render();
|
|
||||||
} catch (e: any) {
|
|
||||||
btn.disabled = false; btn.innerHTML = "Save Address";
|
|
||||||
err.textContent = e.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Address: remove
|
|
||||||
overlay.querySelectorAll("[data-remove-address]").forEach(el => {
|
|
||||||
el.addEventListener("click", async () => {
|
|
||||||
const id = (el as HTMLElement).dataset.removeAddress!;
|
|
||||||
const err = overlay.querySelector("#address-error") as HTMLElement;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${ENCRYPTID_URL}/api/user/addresses/${id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { Authorization: `Bearer ${getAccessToken()}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to remove address");
|
|
||||||
addresses = addresses.filter(a => a.id !== id);
|
|
||||||
render();
|
|
||||||
} catch (e: any) {
|
|
||||||
if (err) err.textContent = e.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Device: rename credential
|
// Device: rename credential
|
||||||
overlay.querySelectorAll("[data-rename-credential]").forEach(el => {
|
overlay.querySelectorAll("[data-rename-credential]").forEach(el => {
|
||||||
el.addEventListener("click", async () => {
|
el.addEventListener("click", async () => {
|
||||||
|
|
@ -2820,6 +2701,13 @@ const STYLES = `
|
||||||
0%, 100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); }
|
0%, 100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); }
|
||||||
50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); }
|
50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); }
|
||||||
}
|
}
|
||||||
|
/* Account alert dot on "My Account" dropdown item */
|
||||||
|
.acct-alert-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: #f87171; margin-left: auto; flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 6px rgba(248,113,113,0.6);
|
||||||
|
animation: recovery-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Persona switcher in dropdown */
|
/* Persona switcher in dropdown */
|
||||||
.dropdown-label {
|
.dropdown-label {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue