From aa23108f5f6085ca1b61822f6485004586ead9d5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 15:39:58 -0700 Subject: [PATCH 1/5] fix(invites): redirect to invited space, improve invite emails - Fix invite accept fetch URL in shell.ts (was missing /api/spaces prefix) - After accepting invite, redirect to the invited space instead of reloading - Notification actionUrls now point to the space subdomain (https://slug.rspace.online) - Direct-add email includes inviter name, role, and space description - Identity invite email includes space name/role context when inviting to a space Co-Authored-By: Claude Opus 4.6 --- server/shell.ts | 7 +++++-- server/spaces.ts | 22 ++++++++++++++-------- src/encryptid/server.ts | 30 ++++++++++++++++++------------ 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/server/shell.ts b/server/shell.ts index 0782ed9..4724979 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -413,12 +413,15 @@ export function renderShell(opts: ShellOptions): string { if (!raw) return; var session = JSON.parse(raw); if (!session || !session.accessToken) return; - fetch('/' + '${escapeAttr(spaceSlug)}' + '/invite/accept', { + fetch('/api/spaces/' + encodeURIComponent('${escapeAttr(spaceSlug)}') + '/invite/accept', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken }, body: JSON.stringify({ inviteToken: inviteToken }), }).then(function(res) { return res.json(); }).then(function(data) { - if (data.ok) { window.location.reload(); } + if (data.ok) { + var slug = data.spaceSlug || '${escapeAttr(spaceSlug)}'; + window.location.href = 'https://' + slug + '.rspace.online'; + } }); } catch(e) {} } diff --git a/server/spaces.ts b/server/spaces.ts index 583abee..0ff8556 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -2194,7 +2194,7 @@ spaces.post("/:slug/invite", async (c) => { spaceSlug: slug, actorDid: claims.sub, actorUsername: claims.username, - actionUrl: `/rspace`, + actionUrl: `https://${slug}.rspace.online`, metadata: { role }, }).catch(() => {}); @@ -2209,15 +2209,21 @@ spaces.post("/:slug/invite", async (c) => { if (inviteTransport) { try { const spaceUrl = `https://${slug}.rspace.online`; + const inviterName = claims.username || "an admin"; await inviteTransport.sendMail({ from: process.env.SMTP_FROM || "rSpace ", to: body.email, - subject: `You've been added to "${slug}" on rSpace`, - html: [ - `

You've been added to ${slug} as a ${role}.

`, - `

Open Space

`, - `

rSpace — collaborative knowledge work

`, - ].join("\n"), + subject: `${inviterName} added you to "${slug}" on rSpace`, + html: ` +
+

You've been added to ${slug}

+

${inviterName} added you to the ${slug} space as a ${role}.

+

You now have access to all the collaborative tools in this space — notes, maps, voting, calendar, and more.

+

+ Open ${slug} +

+

rSpace — collaborative knowledge work

+
`, }); } catch (emailErr: any) { console.error("Direct-add email notification failed:", emailErr.message); @@ -2309,7 +2315,7 @@ spaces.post("/:slug/members/add", async (c) => { spaceSlug: slug, actorDid: claims.sub, actorUsername: claims.username, - actionUrl: `/rspace`, + actionUrl: `https://${slug}.rspace.online`, metadata: { role }, }).catch(() => {}); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index b7b2da4..b5bab16 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -5407,22 +5407,28 @@ app.post('/api/invites/identity', async (c) => { const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`; if (smtpTransport) { try { + const spaceInfo = spaceSlug + ? `

You'll automatically join the ${escapeHtml(spaceSlug)} space as a ${escapeHtml(spaceRole || 'member')}, with access to collaborative notes, maps, voting, calendar, and more.

` + : `

rSpace is a suite of privacy-first collaborative tools — notes, maps, voting, calendar, wallet, and more — powered by passkey authentication (no passwords).

`; + const subjectLine = spaceSlug + ? `${payload.username} invited you to join "${spaceSlug}" on rSpace` + : `${payload.username} invited you to join rSpace`; await smtpTransport.sendMail({ from: CONFIG.smtp.from, to: email, - subject: `${payload.username} invited you to join rSpace`, + subject: subjectLine, html: ` -
-

You've been invited to rSpace

-

${escapeHtml(payload.username)} wants you to join the rSpace ecosystem — a suite of privacy-first tools powered by passkey authentication.

- ${message ? `
"${escapeHtml(message)}"
` : ''} -

Click below to claim your identity and set up your passkey:

-

- Claim your rSpace -

-

This invite expires in 7 days. If you didn't expect this, you can safely ignore it.

-
- `, +
+

${spaceSlug ? `You've been invited to ${escapeHtml(spaceSlug)}` : `You've been invited to rSpace`}

+

${escapeHtml(payload.username)} wants you to join${spaceSlug ? ` the ${escapeHtml(spaceSlug)} space on` : ''} rSpace.

+ ${message ? `
"${escapeHtml(message)}"
` : ''} + ${spaceInfo} +

Click below to create your account and set up your passkey:

+

+ Accept Invitation +

+

This invite expires in 7 days. No passwords needed — you'll use a passkey to sign in securely.

+
`, }); } catch (err) { console.error('EncryptID: Failed to send invite email:', (err as Error).message); From 4d7d2c0108bddcf8656bc70a50ee1da98d2eef23 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:01:10 -0700 Subject: [PATCH 2/5] =?UTF-8?q?fix(rsocials):=20campaign=20wizard=20auth?= =?UTF-8?q?=20=E2=80=94=20read=20correct=20session=20storage=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was reading `encryptid-token` (doesn't exist), now reads `encryptid_session` and extracts `.accessToken` matching the pattern used by all other modules. Co-Authored-By: Claude Opus 4.6 --- modules/rsocials/components/folk-campaign-wizard.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/rsocials/components/folk-campaign-wizard.ts b/modules/rsocials/components/folk-campaign-wizard.ts index 46bc374..3635771 100644 --- a/modules/rsocials/components/folk-campaign-wizard.ts +++ b/modules/rsocials/components/folk-campaign-wizard.ts @@ -134,7 +134,8 @@ export class FolkCampaignWizard extends HTMLElement { } private async apiFetch(path: string, opts: RequestInit = {}): Promise { - const token = (window as any).__authToken || localStorage.getItem('encryptid-token') || ''; + let token = ''; + try { const s = JSON.parse(localStorage.getItem('encryptid_session') || ''); token = s.accessToken || ''; } catch {} return fetch(`${this.basePath}${path}`, { ...opts, headers: { From 5f853322b06b33e1fb9b2c023d6be816d709d06b Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:08:52 -0700 Subject: [PATCH 3/5] fix(rwallet): filter spam tokens via CoinGecko verification ERC-20 tokens not recognized by CoinGecko and valued < $1 by Safe API are now stripped from balance responses, removing fake ETH and airdrop spam. Co-Authored-By: Claude Opus 4.6 --- modules/rwallet/lib/price-feed.ts | 20 ++++++++++++++++++-- modules/rwallet/mod.ts | 8 ++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/rwallet/lib/price-feed.ts b/modules/rwallet/lib/price-feed.ts index ee3f598..3f5ead9 100644 --- a/modules/rwallet/lib/price-feed.ts +++ b/modules/rwallet/lib/price-feed.ts @@ -146,8 +146,9 @@ interface BalanceItem { export async function enrichWithPrices( balances: BalanceItem[], chainId: string, + options?: { filterSpam?: boolean }, ): Promise { - // Skip testnets and unsupported chains + // Skip testnets and unsupported chains — no CoinGecko data to verify against if (!CHAIN_PLATFORM[chainId] && !NATIVE_COIN_ID[chainId]) return balances; // Check if any balance actually needs pricing @@ -165,7 +166,7 @@ export async function enrichWithPrices( try { const priceData = await fetchChainPrices(chainId, tokenAddresses); - return balances.map((b) => { + const enriched = balances.map((b) => { // Skip if already has a real fiat value if (b.fiatBalance && b.fiatBalance !== "0" && parseFloat(b.fiatBalance) > 0) { return b; @@ -194,6 +195,21 @@ export async function enrichWithPrices( fiatBalance: String(fiatValue), }; }); + + if (options?.filterSpam) { + return enriched.filter((b) => { + // Native tokens always pass + if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") return true; + // CoinGecko recognized this token + if (priceData.prices.has(b.tokenAddress.toLowerCase())) return true; + // Safe API independently valued it at >= $1 + if (parseFloat(b.fiatBalance || "0") >= 1) return true; + // Unknown ERC-20 with no verified value = spam + return false; + }); + } + + return enriched; } catch (e) { console.warn(`[price-feed] Failed to enrich prices for chain ${chainId}:`, e); return balances; diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 8dd9fd9..d0b498c 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -47,7 +47,7 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => { fiatBalance: item.fiatBalance || "0", fiatConversion: item.fiatConversion || "0", })); - const enriched = (await enrichWithPrices(data, chainId)) + const enriched = (await enrichWithPrices(data, chainId, { filterSpam: true })) .filter(b => BigInt(b.balance || "0") > 0n); c.header("Cache-Control", "public, max-age=30"); return c.json(enriched); @@ -600,7 +600,7 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => { await Promise.allSettled(promises); - const enriched = await enrichWithPrices(balances, chainId); + const enriched = await enrichWithPrices(balances, chainId, { filterSpam: true }); c.header("Cache-Control", "public, max-age=30"); return c.json(enriched); }); @@ -661,7 +661,7 @@ routes.get("/api/eoa/:address/all-balances", async (c) => { await Promise.allSettled(tokenPromises); if (chainBalances.length > 0) { - const enriched = await enrichWithPrices(chainBalances, chainId); + const enriched = await enrichWithPrices(chainBalances, chainId, { filterSpam: true }); results.push({ chainId, chainName: info.name, balances: enriched }); } }) @@ -700,7 +700,7 @@ routes.get("/api/safe/:address/all-balances", async (c) => { })).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n); if (chainBalances.length > 0) { - const enriched = await enrichWithPrices(chainBalances, chainId); + const enriched = await enrichWithPrices(chainBalances, chainId, { filterSpam: true }); results.push({ chainId, chainName: info.name, balances: enriched }); } } catch {} From 1a422f06ac43aae9949fb231b0ef15e45cdf0c60 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:09:05 -0700 Subject: [PATCH 4/5] feat(canvas): add first-time forgotten shapes explainer tooltip Shows a one-time onboarding tooltip when users first encounter a faded (forgotten) shape. Explains right-click to remember/forget permanently, the Hide Forgotten toggle in profile menu, and highlights the Collective Memory graph as a prototype feature. Persisted via localStorage. Co-Authored-By: Claude Opus 4.6 --- website/canvas.html | 123 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/website/canvas.html b/website/canvas.html index dc40c4e..7200f24 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1406,6 +1406,84 @@ display: none !important; } + /* First-time forgotten explainer tooltip */ + #forgotten-explainer { + display: none; + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--rs-bg-surface, #1e293b); + border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + border-radius: 14px; + padding: 20px 24px; + max-width: 420px; + width: calc(100vw - 32px); + box-shadow: 0 12px 40px rgba(0,0,0,0.5); + z-index: 300000; + color: var(--rs-text-primary, #e2e8f0); + font-size: 0.85rem; + line-height: 1.55; + animation: explainer-slide-up 0.35s ease-out; + } + @keyframes explainer-slide-up { + from { opacity: 0; transform: translateX(-50%) translateY(20px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } + } + #forgotten-explainer.visible { display: block; } + #forgotten-explainer h4 { + margin: 0 0 10px; + font-size: 0.95rem; + color: var(--rs-text-primary, #e2e8f0); + } + #forgotten-explainer p { + margin: 0 0 8px; + color: var(--rs-text-secondary, #94a3b8); + } + #forgotten-explainer .tip-row { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 6px; + } + #forgotten-explainer .tip-icon { + flex-shrink: 0; + font-size: 1rem; + line-height: 1.4; + } + #forgotten-explainer .tip-label { + font-size: 0.82rem; + color: var(--rs-text-secondary, #94a3b8); + } + #forgotten-explainer .tip-label strong { + color: var(--rs-text-primary, #e2e8f0); + } + #forgotten-explainer .proto-badge { + display: inline-block; + font-size: 0.68rem; + padding: 1px 6px; + border-radius: 4px; + background: rgba(124,58,237,0.15); + color: #a78bfa; + margin-left: 4px; + vertical-align: middle; + } + #forgotten-explainer .dismiss-btn { + display: block; + margin: 14px auto 0; + padding: 7px 20px; + background: rgba(20,184,166,0.12); + border: 1px solid rgba(20,184,166,0.25); + color: #14b8a6; + border-radius: 8px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + } + #forgotten-explainer .dismiss-btn:hover { + background: rgba(20,184,166,0.2); + } + /* Cross-space shape styling — colored border + source badge */ .rspace-cross-space-shape { outline: 2px dashed rgba(99, 102, 241, 0.5) !important; @@ -2128,6 +2206,24 @@ +
+

👻 Faded shapes are "forgotten"

+

When you or others close a shape, it doesn't disappear — it fades. This is collective memory.

+
+ 🖱 + Right-click a faded shape to Remember it (restore) or Forget permanently (delete) +
+
+ 👁 + Toggle Hide Forgotten in your profile menu to show/hide faded shapes +
+
+ 🔮 + The Collective Memory graph prototype visualizes what the group remembers vs. forgets +
+ +
+

💭 Recent Changes

@@ -6068,6 +6164,33 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest if (memoryPanel.classList.contains("open")) renderMemoryPanel(); }); + // First-time forgotten explainer tooltip + { + const SEEN_KEY = 'rspace_forgotten_explainer_seen'; + let explainerShown = false; + + function showForgottenExplainer() { + if (explainerShown || localStorage.getItem(SEEN_KEY)) return; + explainerShown = true; + // Delay slightly so the faded shape is visible first + setTimeout(() => { + const el = document.getElementById('forgotten-explainer'); + if (el) el.classList.add('visible'); + }, 800); + } + + document.getElementById('forgotten-explainer-dismiss')?.addEventListener('click', () => { + localStorage.setItem(SEEN_KEY, '1'); + const el = document.getElementById('forgotten-explainer'); + if (el) el.classList.remove('visible'); + }); + + sync.addEventListener("shape-state-changed", (e) => { + if (e.detail?.state === 'forgotten') showForgottenExplainer(); + }); + sync.addEventListener("shape-forgotten", () => showForgottenExplainer()); + } + // Re-dispatch comment-pins-changed on window so header bell can update sync.addEventListener("comment-pins-changed", () => { window.dispatchEvent(new CustomEvent("comment-pins-changed")); From 55b973ebc26714c4614e761a6d5774b0cb3d6521 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:10:11 -0700 Subject: [PATCH 5/5] fix(blender): use correct Ollama model (qwen2.5-coder:7b) qwen2.5:14b doesn't exist on the server, causing silent 404 from Ollama and 502 to the client. Also added error logging for non-ok Ollama responses. Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index fd3aba1..b3ccb3b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1594,7 +1594,7 @@ app.post("/api/blender-gen", async (c) => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - model: process.env.OLLAMA_MODEL || "qwen2.5:14b", + model: process.env.OLLAMA_MODEL || "qwen2.5-coder:7b", prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`, stream: false, }), @@ -1606,6 +1606,9 @@ app.post("/api/blender-gen", async (c) => { // Extract code block if wrapped in markdown const codeMatch = script.match(/```(?:python)?\n([\s\S]*?)```/); if (codeMatch) script = codeMatch[1].trim(); + } else { + const errText = await llmRes.text().catch(() => ""); + console.error(`[blender-gen] Ollama ${llmRes.status}: ${errText}`); } } catch (e) { console.error("[blender-gen] LLM error:", e);