diff --git a/lib/community-sync.ts b/lib/community-sync.ts index f461475b..ac11b937 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -166,6 +166,7 @@ export class CommunitySync extends EventTarget { #syncedDebounceTimer: ReturnType | null = null; #initialSyncFired = false; #wsUrl: string | null = null; + #localDID: string = ''; // ── Undo/Redo state ── #undoStack: UndoEntry[] = []; @@ -195,6 +196,11 @@ export class CommunitySync extends EventTarget { } } + /** Set the local user's DID so forgotten-shape filtering is per-user. */ + setLocalDID(did: string): void { + this.#localDID = did; + } + /** * Load document and sync state from offline cache. * Call BEFORE connect() to show cached content immediately. @@ -878,9 +884,14 @@ export class CommunitySync extends EventTarget { for (const [id, shapeData] of Object.entries(shapes)) { const d = shapeData as Record; if (d.deleted === true) continue; // Deleted: not in DOM - this.#applyShapeToDOM(shapeData); - // If forgotten (faded), emit state-changed so canvas can apply visual + // Skip shapes this user has forgotten — one delete = gone from their view const fb = d.forgottenBy; + if (this.#localDID && fb && typeof fb === 'object' + && (fb as Record)[this.#localDID]) { + continue; + } + 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) { this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId: id, state: 'forgotten', data: shapeData } @@ -945,6 +956,14 @@ export class CommunitySync extends EventTarget { const d = shapeData as Record; const state = this.getShapeVisualState(shapeId); + // Skip shapes this user has forgotten — don't create/update DOM + const fb = d.forgottenBy; + if (this.#localDID && fb && typeof fb === 'object' + && (fb as Record)[this.#localDID]) { + this.#removeShapeFromDOM(shapeId); + continue; + } + if (state === 'deleted') { // Hard-deleted: remove from DOM this.#removeShapeFromDOM(shapeId); @@ -952,7 +971,7 @@ export class CommunitySync extends EventTarget { detail: { shapeId, state: 'deleted', data: shapeData } })); } else if (state === 'forgotten') { - // Forgotten: keep in DOM, emit state change for fade visual + // Forgotten by others: keep in DOM, emit state change for fade visual this.#applyShapeToDOM(shapeData); this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'forgotten', data: shapeData } diff --git a/lib/folk-embed.ts b/lib/folk-embed.ts index 704768bd..487dc46c 100644 --- a/lib/folk-embed.ts +++ b/lib/folk-embed.ts @@ -222,6 +222,7 @@ export class FolkEmbed extends FolkShape { #url: string | null = null; #error: string | null = null; + #embedRendered = false; get url() { return this.#url; @@ -320,15 +321,34 @@ export class FolkEmbed extends FolkShape { this.dispatchEvent(new CustomEvent("close")); }); - // If URL is already set, render embed + // Defer embed rendering until shape enters viewport if (this.#url) { - const embedUrl = transformUrl(this.#url); - this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, this.#url, embedUrl); + if (this.hasBeenVisible) { + const embedUrl = transformUrl(this.#url); + this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, this.#url, embedUrl); + } else { + // Show URL text as lightweight placeholder + titleText.textContent = getDisplayTitle(this.#url); + } } return root; } + protected override onBecameVisible(): void { + if (this.#url && !this.#embedRendered) { + const root = this.renderRoot as ShadowRoot; + const content = root.querySelector(".content") as HTMLElement; + const urlInputContainer = root.querySelector(".url-input-container") as HTMLElement; + const titleText = root.querySelector(".title-text") as HTMLElement; + const headerTitle = root.querySelector(".header-title") as HTMLElement; + if (content && urlInputContainer && titleText && headerTitle) { + const embedUrl = transformUrl(this.#url); + this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, this.#url, embedUrl); + } + } + } + #renderEmbed( content: HTMLElement, urlInputContainer: HTMLElement, @@ -337,6 +357,7 @@ export class FolkEmbed extends FolkShape { originalUrl: string, embedUrl: string | null ) { + this.#embedRendered = true; // Update header titleText.textContent = getDisplayTitle(originalUrl); const favicon = document.createElement("img"); diff --git a/lib/folk-feed.ts b/lib/folk-feed.ts index d7bbd837..46a6273e 100644 --- a/lib/folk-feed.ts +++ b/lib/folk-feed.ts @@ -64,8 +64,7 @@ export class FolkFeed extends FolkShape { connectedCallback() { super.connectedCallback(); this.#buildUI(); - this.#fetchFeed(); - this.#startAutoRefresh(); + // Defer API calls until shape is visible (onBecameVisible handles it) } disconnectedCallback() { @@ -73,6 +72,11 @@ export class FolkFeed extends FolkShape { this.#stopAutoRefresh(); } + protected override onBecameVisible(): void { + this.#fetchFeed(); + this.#startAutoRefresh(); + } + attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) { super.attributeChangedCallback(name, oldVal, newVal); if (["source-module", "feed-id", "feed-filter", "max-items"].includes(name)) { diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index 59c48f11..a702106b 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -634,6 +634,7 @@ export class FolkRApp extends FolkShape { #refreshTimer: ReturnType | null = null; #modeToggleBtn: HTMLButtonElement | null = null; #resolvedPorts: PortDescriptor[] | null = null; + #contentLoaded = false; get moduleId() { return this.#moduleId; } set moduleId(value: string) { @@ -815,9 +816,13 @@ export class FolkRApp extends FolkShape { // Register for enabled-state broadcasts FolkRApp.#instances.add(this); - // Load content + // Defer heavy content loading until shape enters viewport if (this.#moduleId) { - this.#renderContent(); + if (this.hasBeenVisible) { + this.#renderContent(); + } else { + this.#showLoading(); + } } else { this.#showPicker(); } @@ -825,8 +830,8 @@ export class FolkRApp extends FolkShape { return root; } - disconnectedCallback() { - super.disconnectedCallback?.(); + override disconnectedCallback() { + super.disconnectedCallback(); FolkRApp.#instances.delete(this); if (this.#messageHandler) { window.removeEventListener("message", this.#messageHandler); @@ -838,6 +843,23 @@ export class FolkRApp extends FolkShape { } } + protected override onBecameVisible(): void { + if (this.#moduleId && !this.#contentLoaded) { + this.#renderContent(); + } + } + + #showLoading() { + if (!this.#contentEl) return; + const meta = MODULE_META[this.#moduleId]; + this.#contentEl.innerHTML = ` +
+
+ ${meta?.name || this.#moduleId} +
+ `; + } + #buildSwitcher(switcherEl: HTMLElement) { const enabledSet = FolkRApp.enabledModuleIds ?? ((window as any).__rspaceEnabledModules ? new Set((window as any).__rspaceEnabledModules as string[]) : null); @@ -955,16 +977,9 @@ export class FolkRApp extends FolkShape { } } - /** Derive the base module URL path, accounting for subdomain routing */ + /** Derive the base module URL path — spaces are always subdomains */ #getModulePath(): string { - if (!this.#spaceSlug) { - const pathParts = window.location.pathname.split("/").filter(Boolean); - if (pathParts.length >= 1) this.#spaceSlug = pathParts[0]; - } - const space = this.#spaceSlug || "demo"; - const hostname = window.location.hostname; - const onSubdomain = hostname.split(".").length >= 3 && hostname.startsWith(space + "."); - return onSubdomain ? `/${this.#moduleId}` : `/${space}/${this.#moduleId}`; + return `/${this.#moduleId}`; } /** Check if this shape's module is currently disabled */ @@ -1014,6 +1029,8 @@ export class FolkRApp extends FolkShape { #renderContent() { if (!this.#contentEl || !this.#moduleId) return; + this.#contentLoaded = true; + // Block rendering if module is disabled if (this.#isModuleDisabled()) { this.#showDisabled(); return; } diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 3f076f4f..105b5a5b 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -224,6 +224,32 @@ export class FolkShape extends FolkElement { static tagName = "folk-shape"; static styles = styles; + // ── Viewport-based lazy loading ── + static #visibilityObserver: IntersectionObserver | null = null; + static #ensureObserver(): IntersectionObserver { + if (!FolkShape.#visibilityObserver) { + FolkShape.#visibilityObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const shape = entry.target as FolkShape; + if (entry.isIntersecting && !shape.#hasBeenVisible) { + shape.#hasBeenVisible = true; + shape.onBecameVisible(); + } + } + }, + { rootMargin: '500px' } + ); + } + return FolkShape.#visibilityObserver; + } + + #hasBeenVisible = false; + get hasBeenVisible(): boolean { return this.#hasBeenVisible; } + + /** Called once when shape first enters viewport (+500px margin). Override to defer heavy init. */ + protected onBecameVisible(): void {} + #internals = this.attachInternals(); #attrWidth: Dimension = 0; #attrHeight: Dimension = 0; @@ -466,6 +492,16 @@ export class FolkShape extends FolkElement { return root; } + override connectedCallback() { + super.connectedCallback(); + FolkShape.#ensureObserver().observe(this); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + FolkShape.#visibilityObserver?.unobserve(this); + } + getTransformDOMRect() { return this.#readonlyRect; } diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index cabbcc6d..3dbfbd5f 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -875,9 +875,13 @@ export class RStackIdentity extends HTMLElement { `}
- ${showPicker ? '' : ``} + ${showPicker ? '' : ` + + + `}
+
Powered by EncryptID
@@ -890,6 +894,7 @@ export class RStackIdentity extends HTMLElement {

Create your EncryptID

Set up a secure, passwordless identity.

+
@@ -921,10 +926,13 @@ export class RStackIdentity extends HTMLElement { if (pickerBtn) { pickerBtn.style.opacity = "0.6"; pickerBtn.style.pointerEvents = "none"; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = ''; } try { + // Send as email or username depending on input format + const isEmail = loginUsername.includes("@"); + const authBody = isEmail ? { email: loginUsername } : { username: loginUsername }; const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: loginUsername }), + body: JSON.stringify(authBody), }); if (!startRes.ok) throw new Error("Failed to start authentication"); const { options: serverOptions, userFound } = await startRes.json(); @@ -981,11 +989,40 @@ export class RStackIdentity extends HTMLElement { } }; + const handleMagicLink = async () => { + const input = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null; + const msgEl = overlay.querySelector("#magic-link-msg") as HTMLElement | null; + const errEl = overlay.querySelector("#auth-error") as HTMLElement | null; + const value = input?.value.trim() || ""; + if (!value || !value.includes("@")) { + if (errEl) errEl.textContent = "Enter an email address to receive a magic link."; + input?.focus(); + return; + } + if (errEl) errEl.textContent = ""; + const btn = overlay.querySelector('[data-action="send-magic-link"]') as HTMLButtonElement | null; + if (btn) { btn.disabled = true; btn.innerHTML = ' Sending...'; } + try { + await fetch(`${ENCRYPTID_URL}/api/auth/magic-link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: value }), + }); + if (msgEl) { msgEl.style.display = ""; msgEl.style.color = "#22c55e"; msgEl.textContent = "Login link sent! Check your inbox."; } + } catch { + if (msgEl) { msgEl.style.display = ""; msgEl.style.color = "#f87171"; msgEl.textContent = "Failed to send. Try again."; } + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = "📧 Send Magic Link"; } + } + }; + const handleRegister = async () => { const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement; + const emailInput = overlay.querySelector("#auth-email") as HTMLInputElement | null; const errEl = overlay.querySelector("#auth-error") as HTMLElement; const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement; const username = usernameInput.value.trim(); + const email = emailInput?.value.trim() || ""; if (!username) { errEl.textContent = "Please enter a username."; @@ -1037,6 +1074,7 @@ export class RStackIdentity extends HTMLElement { }, userId, username, + ...(email ? { email } : {}), }), }); const data = await completeRes.json().catch(() => null); @@ -1062,6 +1100,7 @@ export class RStackIdentity extends HTMLElement { callbacks?.onCancel?.(); }); overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn); + overlay.querySelector('[data-action="send-magic-link"]')?.addEventListener("click", handleMagicLink); overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister); overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => { mode = "register"; @@ -1099,6 +1138,13 @@ export class RStackIdentity extends HTMLElement { }); }); overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") { + // Tab to email field if empty, otherwise register + const emailInput = overlay.querySelector("#auth-email") as HTMLInputElement | null; + if (emailInput && !emailInput.value.trim()) { emailInput.focus(); } else { handleRegister(); } + } + }); + overlay.querySelector("#auth-email")?.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") handleRegister(); }); overlay.querySelector("#auth-signin-username")?.addEventListener("keydown", (e) => { diff --git a/website/canvas.html b/website/canvas.html index 64d93e6f..78f06b3e 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2563,6 +2563,9 @@ window.__communitySync?.disconnect?.(); window.location.href = "/"; } + + // Re-sync DID for forgotten-shape filtering after auth changes + window.__communitySync?.setLocalDID?.(getLocalDID()); }); // Load module list for app switcher and tab bar + menu @@ -3217,6 +3220,7 @@ // Initialize offline store and CommunitySync const offlineStore = new OfflineStore(); const sync = new CommunitySync(communitySlug, offlineStore); + sync.setLocalDID(getLocalDID()); window.__communitySync = sync; // Wire history panel to CommunitySync doc