Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-25 16:10:14 -07:00
commit 2c710bef68
8 changed files with 188 additions and 30 deletions

View File

@ -134,7 +134,8 @@ export class FolkCampaignWizard extends HTMLElement {
} }
private async apiFetch(path: string, opts: RequestInit = {}): Promise<Response> { private async apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
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}`, { return fetch(`${this.basePath}${path}`, {
...opts, ...opts,
headers: { headers: {

View File

@ -146,8 +146,9 @@ interface BalanceItem {
export async function enrichWithPrices( export async function enrichWithPrices(
balances: BalanceItem[], balances: BalanceItem[],
chainId: string, chainId: string,
options?: { filterSpam?: boolean },
): Promise<BalanceItem[]> { ): Promise<BalanceItem[]> {
// 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; if (!CHAIN_PLATFORM[chainId] && !NATIVE_COIN_ID[chainId]) return balances;
// Check if any balance actually needs pricing // Check if any balance actually needs pricing
@ -165,7 +166,7 @@ export async function enrichWithPrices(
try { try {
const priceData = await fetchChainPrices(chainId, tokenAddresses); const priceData = await fetchChainPrices(chainId, tokenAddresses);
return balances.map((b) => { const enriched = balances.map((b) => {
// Skip if already has a real fiat value // Skip if already has a real fiat value
if (b.fiatBalance && b.fiatBalance !== "0" && parseFloat(b.fiatBalance) > 0) { if (b.fiatBalance && b.fiatBalance !== "0" && parseFloat(b.fiatBalance) > 0) {
return b; return b;
@ -194,6 +195,21 @@ export async function enrichWithPrices(
fiatBalance: String(fiatValue), 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) { } catch (e) {
console.warn(`[price-feed] Failed to enrich prices for chain ${chainId}:`, e); console.warn(`[price-feed] Failed to enrich prices for chain ${chainId}:`, e);
return balances; return balances;

View File

@ -47,7 +47,7 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => {
fiatBalance: item.fiatBalance || "0", fiatBalance: item.fiatBalance || "0",
fiatConversion: item.fiatConversion || "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); .filter(b => BigInt(b.balance || "0") > 0n);
c.header("Cache-Control", "public, max-age=30"); c.header("Cache-Control", "public, max-age=30");
return c.json(enriched); return c.json(enriched);
@ -600,7 +600,7 @@ routes.get("/api/eoa/:chainId/:address/balances", async (c) => {
await Promise.allSettled(promises); 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"); c.header("Cache-Control", "public, max-age=30");
return c.json(enriched); return c.json(enriched);
}); });
@ -661,7 +661,7 @@ routes.get("/api/eoa/:address/all-balances", async (c) => {
await Promise.allSettled(tokenPromises); await Promise.allSettled(tokenPromises);
if (chainBalances.length > 0) { 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 }); 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); })).filter((b: BalanceItem) => BigInt(b.balance || "0") > 0n);
if (chainBalances.length > 0) { 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 }); results.push({ chainId, chainName: info.name, balances: enriched });
} }
} catch {} } catch {}

View File

@ -1594,7 +1594,7 @@ app.post("/api/blender-gen", async (c) => {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ 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.`, 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, stream: false,
}), }),
@ -1606,6 +1606,9 @@ app.post("/api/blender-gen", async (c) => {
// Extract code block if wrapped in markdown // Extract code block if wrapped in markdown
const codeMatch = script.match(/```(?:python)?\n([\s\S]*?)```/); const codeMatch = script.match(/```(?:python)?\n([\s\S]*?)```/);
if (codeMatch) script = codeMatch[1].trim(); if (codeMatch) script = codeMatch[1].trim();
} else {
const errText = await llmRes.text().catch(() => "");
console.error(`[blender-gen] Ollama ${llmRes.status}: ${errText}`);
} }
} catch (e) { } catch (e) {
console.error("[blender-gen] LLM error:", e); console.error("[blender-gen] LLM error:", e);

View File

@ -413,12 +413,15 @@ export function renderShell(opts: ShellOptions): string {
if (!raw) return; if (!raw) return;
var session = JSON.parse(raw); var session = JSON.parse(raw);
if (!session || !session.accessToken) return; if (!session || !session.accessToken) return;
fetch('/' + '${escapeAttr(spaceSlug)}' + '/invite/accept', { fetch('/api/spaces/' + encodeURIComponent('${escapeAttr(spaceSlug)}') + '/invite/accept', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken }, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
body: JSON.stringify({ inviteToken: inviteToken }), body: JSON.stringify({ inviteToken: inviteToken }),
}).then(function(res) { return res.json(); }).then(function(data) { }).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) {} } catch(e) {}
} }

View File

@ -2194,7 +2194,7 @@ spaces.post("/:slug/invite", async (c) => {
spaceSlug: slug, spaceSlug: slug,
actorDid: claims.sub, actorDid: claims.sub,
actorUsername: claims.username, actorUsername: claims.username,
actionUrl: `/rspace`, actionUrl: `https://${slug}.rspace.online`,
metadata: { role }, metadata: { role },
}).catch(() => {}); }).catch(() => {});
@ -2209,15 +2209,21 @@ spaces.post("/:slug/invite", async (c) => {
if (inviteTransport) { if (inviteTransport) {
try { try {
const spaceUrl = `https://${slug}.rspace.online`; const spaceUrl = `https://${slug}.rspace.online`;
const inviterName = claims.username || "an admin";
await inviteTransport.sendMail({ await inviteTransport.sendMail({
from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>", from: process.env.SMTP_FROM || "rSpace <noreply@rmail.online>",
to: body.email, to: body.email,
subject: `You've been added to "${slug}" on rSpace`, subject: `${inviterName} added you to "${slug}" on rSpace`,
html: [ html: `
`<p>You've been added to <strong>${slug}</strong> as a <strong>${role}</strong>.</p>`, <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
`<p><a href="${spaceUrl}" style="display:inline-block;padding:12px 24px;background:#14b8a6;color:white;border-radius:8px;text-decoration:none;font-weight:600;">Open Space</a></p>`, <h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You've been added to ${slug}</h2>
`<p style="color:#64748b;font-size:12px;">rSpace — collaborative knowledge work</p>`, <p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> added you to the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
].join("\n"), <p style="color:#475569;line-height:1.6;">You now have access to all the collaborative tools in this space notes, maps, voting, calendar, and more.</p>
<p style="text-align:center;margin:2rem 0;">
<a href="${spaceUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Open ${slug}</a>
</p>
<p style="color:#94a3b8;font-size:0.85rem;">rSpace collaborative knowledge work</p>
</div>`,
}); });
} catch (emailErr: any) { } catch (emailErr: any) {
console.error("Direct-add email notification failed:", emailErr.message); console.error("Direct-add email notification failed:", emailErr.message);
@ -2309,7 +2315,7 @@ spaces.post("/:slug/members/add", async (c) => {
spaceSlug: slug, spaceSlug: slug,
actorDid: claims.sub, actorDid: claims.sub,
actorUsername: claims.username, actorUsername: claims.username,
actionUrl: `/rspace`, actionUrl: `https://${slug}.rspace.online`,
metadata: { role }, metadata: { role },
}).catch(() => {}); }).catch(() => {});

View File

@ -5407,22 +5407,28 @@ app.post('/api/invites/identity', async (c) => {
const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`; const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`;
if (smtpTransport) { if (smtpTransport) {
try { try {
const spaceInfo = spaceSlug
? `<p style="color:#475569;line-height:1.6;">You'll automatically join the <strong>${escapeHtml(spaceSlug)}</strong> space as a <strong>${escapeHtml(spaceRole || 'member')}</strong>, with access to collaborative notes, maps, voting, calendar, and more.</p>`
: `<p style="color:#475569;line-height:1.6;">rSpace is a suite of privacy-first collaborative tools — notes, maps, voting, calendar, wallet, and more — powered by passkey authentication (no passwords).</p>`;
const subjectLine = spaceSlug
? `${payload.username} invited you to join "${spaceSlug}" on rSpace`
: `${payload.username} invited you to join rSpace`;
await smtpTransport.sendMail({ await smtpTransport.sendMail({
from: CONFIG.smtp.from, from: CONFIG.smtp.from,
to: email, to: email,
subject: `${payload.username} invited you to join rSpace`, subject: subjectLine,
html: ` html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 2rem;"> <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
<h2 style="color: #1a1a2e;">You've been invited to rSpace</h2> <h2 style="color:#1a1a2e;margin-bottom:0.5rem;">${spaceSlug ? `You've been invited to ${escapeHtml(spaceSlug)}` : `You've been invited to rSpace`}</h2>
<p><strong>${escapeHtml(payload.username)}</strong> wants you to join the rSpace ecosystem a suite of privacy-first tools powered by passkey authentication.</p> <p style="color:#475569;line-height:1.6;"><strong>${escapeHtml(payload.username)}</strong> wants you to join${spaceSlug ? ` the <strong>${escapeHtml(spaceSlug)}</strong> space on` : ''} rSpace.</p>
${message ? `<blockquote style="border-left: 3px solid #7c3aed; padding: 0.5rem 1rem; margin: 1rem 0; color: #475569; background: #f8fafc; border-radius: 0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''} ${message ? `<blockquote style="border-left:3px solid #7c3aed;padding:0.5rem 1rem;margin:1rem 0;color:#475569;background:#f8fafc;border-radius:0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
<p>Click below to claim your identity and set up your passkey:</p> ${spaceInfo}
<p style="text-align: center; margin: 2rem 0;"> <p style="color:#475569;line-height:1.6;">Click below to create your account and set up your passkey:</p>
<a href="${joinLink}" style="display: inline-block; padding: 0.85rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 0.5rem; font-weight: 600; font-size: 1rem;">Claim your rSpace</a> <p style="text-align:center;margin:2rem 0;">
</p> <a href="${joinLink}" style="display:inline-block;padding:0.85rem 2rem;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#fff;text-decoration:none;border-radius:0.5rem;font-weight:600;font-size:1rem;">Accept Invitation</a>
<p style="color: #94a3b8; font-size: 0.85rem;">This invite expires in 7 days. If you didn't expect this, you can safely ignore it.</p> </p>
</div> <p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. No passwords needed you'll use a passkey to sign in securely.</p>
`, </div>`,
}); });
} catch (err) { } catch (err) {
console.error('EncryptID: Failed to send invite email:', (err as Error).message); console.error('EncryptID: Failed to send invite email:', (err as Error).message);

View File

@ -1406,6 +1406,84 @@
display: none !important; 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 */ /* Cross-space shape styling — colored border + source badge */
.rspace-cross-space-shape { .rspace-cross-space-shape {
outline: 2px dashed rgba(99, 102, 241, 0.5) !important; outline: 2px dashed rgba(99, 102, 241, 0.5) !important;
@ -2128,6 +2206,24 @@
<!-- Hidden button for JS toggle reference (injected into identity dropdown) --> <!-- Hidden button for JS toggle reference (injected into identity dropdown) -->
<button id="toggle-hide-forgotten" style="display:none" title="Hide forgotten items"></button> <button id="toggle-hide-forgotten" style="display:none" title="Hide forgotten items"></button>
<div id="forgotten-explainer">
<h4>👻 Faded shapes are "forgotten"</h4>
<p>When you or others close a shape, it doesn't disappear — it fades. This is <strong>collective memory</strong>.</p>
<div class="tip-row">
<span class="tip-icon">🖱</span>
<span class="tip-label"><strong>Right-click</strong> a faded shape to <strong>Remember</strong> it (restore) or <strong>Forget permanently</strong> (delete)</span>
</div>
<div class="tip-row">
<span class="tip-icon">👁</span>
<span class="tip-label">Toggle <strong>Hide Forgotten</strong> in your profile menu to show/hide faded shapes</span>
</div>
<div class="tip-row">
<span class="tip-icon">🔮</span>
<span class="tip-label">The <strong>Collective Memory</strong> graph <span class="proto-badge">prototype</span> visualizes what the group remembers vs. forgets</span>
</div>
<button class="dismiss-btn" id="forgotten-explainer-dismiss">Got it</button>
</div>
<div id="memory-panel"> <div id="memory-panel">
<div id="memory-panel-header"> <div id="memory-panel-header">
<h3>💭 Recent Changes</h3> <h3>💭 Recent Changes</h3>
@ -6068,6 +6164,33 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
if (memoryPanel.classList.contains("open")) renderMemoryPanel(); 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 // Re-dispatch comment-pins-changed on window so header bell can update
sync.addEventListener("comment-pins-changed", () => { sync.addEventListener("comment-pins-changed", () => {
window.dispatchEvent(new CustomEvent("comment-pins-changed")); window.dispatchEvent(new CustomEvent("comment-pins-changed"));