feat(identity): add My Wallets panel to avatar dropdown

Adds a wallet modal accessible from the identity dropdown showing:
- rIdentity wallet card with username, DID, and passkey badge
- Browser wallet discovery via EIP-6963 (MetaMask, Rainbow, etc.)
- Connect flow with eth_requestAccounts
- Quick link to open the full rWallet module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 14:23:00 -07:00
parent c36b0abc32
commit e47cd35a34
1 changed files with 215 additions and 1 deletions

View File

@ -6,7 +6,7 @@
* Refactored from lib/rspace-header.ts into a standalone web component.
*/
import { rspaceNavUrl, getCurrentModule } from "../url-helpers";
import { rspaceNavUrl, getCurrentModule, getCurrentSpace } from "../url-helpers";
import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } from "../local-first/encryptid-bridge";
const SESSION_KEY = "encryptid_session";
@ -28,6 +28,32 @@ interface SessionState {
};
}
// ── EIP-6963 browser wallet discovery ──
interface _EIP6963Provider {
info: { uuid: string; name: string; icon: string; rdns: string };
provider: { request: (args: { method: string; params?: unknown[] }) => Promise<unknown> };
}
class _WalletDiscovery {
providers: _EIP6963Provider[] = [];
#cb: (() => void) | null = null;
start(onChange: () => void) {
this.#cb = onChange;
window.addEventListener("eip6963:announceProvider", ((e: CustomEvent) => {
const detail = e.detail as _EIP6963Provider;
if (!this.providers.some((p) => p.info.uuid === detail.info.uuid)) {
this.providers.push(detail);
this.#cb?.();
}
}) as EventListener);
window.dispatchEvent(new Event("eip6963:requestProvider"));
}
stop() { this.#cb = null; }
}
// ── Cross-subdomain cookie helpers ──
function _isRspace(): boolean {
@ -353,6 +379,7 @@ export class RStackIdentity extends HTMLElement {
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-action="my-account">👤 My Account</button>
<button class="dropdown-item" data-action="my-spaces">🌐 My Spaces</button>
<button class="dropdown-item" data-action="my-wallets">💰 My Wallets</button>
<div class="dropdown-divider"></div>
<div class="dropdown-theme-row">
<span class="theme-icon"></span>
@ -400,6 +427,8 @@ export class RStackIdentity extends HTMLElement {
this.#showAccountModal();
} else if (action === "my-spaces") {
this.#showSpacesModal();
} else if (action === "my-wallets") {
this.#showWalletsModal();
}
});
});
@ -1202,6 +1231,103 @@ export class RStackIdentity extends HTMLElement {
render();
}
// ── Wallets modal ──
#showWalletsModal(): void {
if (document.querySelector(".rstack-wallets-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-wallets-overlay";
const session = getSession();
const username = session?.claims.username || "";
const did = session?.claims.did || session?.claims.sub || "";
const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase();
const truncDid = did.length > 32 ? did.slice(0, 16) + "..." + did.slice(-8) : did;
const discovery = new _WalletDiscovery();
const connectedAddrs = new Map<string, string>(); // uuid → address
const close = () => { discovery.stop(); overlay.remove(); };
const render = () => {
const browserSection = discovery.providers.length
? discovery.providers.map((p) => {
const addr = connectedAddrs.get(p.info.uuid);
return `
<div class="wallet-provider-card" data-uuid="${p.info.uuid}">
<img class="wallet-provider-icon" src="${p.info.icon}" alt="${p.info.name}" />
<div class="wallet-provider-info">
<div class="wallet-provider-name">${p.info.name}</div>
${addr
? `<div class="wallet-provider-addr">${addr.slice(0, 6)}...${addr.slice(-4)}</div>`
: `<button class="wallet-connect-btn" data-connect="${p.info.uuid}">Connect</button>`
}
</div>
</div>`;
}).join("")
: `<p class="wallet-empty">No browser wallets detected.<br><span style="font-size:0.75rem;color:var(--rs-text-muted)">Install MetaMask, Rainbow, or another EIP-6963 wallet.</span></p>`;
overlay.innerHTML = `
<style>${MODAL_STYLES}${WALLETS_STYLES}</style>
<div class="wallets-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Wallets</h2>
<div class="wallet-section-label">rIdentity Wallet</div>
<div class="wallet-identity-card">
<div class="wallet-identity-avatar">${initial}</div>
<div class="wallet-identity-info">
<div class="wallet-identity-name">${username || "Anonymous"}</div>
<div class="wallet-identity-did" title="${did}">${truncDid}</div>
</div>
<span class="wallet-badge">Passkey</span>
</div>
<div class="wallet-section-label">Browser Wallets</div>
<div class="wallet-providers">${browserSection}</div>
<div class="wallet-footer">
<button class="wallet-open-btn" data-action="open-rwallet">Open rWallet </button>
</div>
</div>
`;
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="open-rwallet"]')?.addEventListener("click", () => {
close();
const space = _getCurrentSpace();
window.location.href = _navUrl(space, "rwallet");
});
overlay.querySelectorAll("[data-connect]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const uuid = (btn as HTMLElement).dataset.connect!;
const provider = discovery.providers.find((p) => p.info.uuid === uuid);
if (!provider) return;
(btn as HTMLButtonElement).textContent = "Connecting...";
(btn as HTMLButtonElement).disabled = true;
try {
const accounts = await provider.provider.request({ method: "eth_requestAccounts" }) as string[];
if (accounts?.[0]) {
connectedAddrs.set(uuid, accounts[0]);
render();
}
} catch {
(btn as HTMLButtonElement).textContent = "Rejected";
setTimeout(() => { (btn as HTMLButtonElement).textContent = "Connect"; (btn as HTMLButtonElement).disabled = false; }, 2000);
}
});
});
};
document.body.appendChild(overlay);
discovery.start(render);
render();
}
// ── Spaces modal ──
#showSpacesModal(): void {
@ -1759,3 +1885,91 @@ const SPACES_STYLES = `
background: linear-gradient(135deg, #06b6d4, #7c3aed);
}
`;
const WALLETS_STYLES = `
.rstack-wallets-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.wallets-modal {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; padding: 2rem; max-width: 480px; width: 92%;
max-height: 85vh; overflow-y: auto; color: var(--rs-text-primary); position: relative;
box-shadow: var(--rs-shadow-lg); animation: slideUp 0.3s;
}
.wallets-modal h2 {
font-size: 1.5rem; margin-bottom: 0.5rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.wallet-section-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--rs-text-muted); margin: 1.25rem 0 0.5rem;
}
.wallet-identity-card {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-radius: 12px;
background: var(--rs-bg-hover); border: 1px solid rgba(6,182,212,0.3);
}
.wallet-identity-avatar {
width: 42px; height: 42px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.9rem; color: white;
}
.wallet-identity-info { flex: 1; min-width: 0; }
.wallet-identity-name {
font-weight: 600; font-size: 0.95rem; color: var(--rs-text-primary);
}
.wallet-identity-did {
font-size: 0.75rem; color: var(--rs-text-muted); font-family: monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wallet-badge {
font-size: 0.7rem; font-weight: 600; color: #06b6d4;
background: rgba(6,182,212,0.12); padding: 3px 10px; border-radius: 10px;
white-space: nowrap;
}
.wallet-providers { display: flex; flex-direction: column; gap: 8px; }
.wallet-provider-card {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 10px;
background: var(--rs-bg-hover); border: 1px solid var(--rs-border);
transition: border-color 0.2s;
}
.wallet-provider-card:hover { border-color: rgba(6,182,212,0.4); }
.wallet-provider-icon {
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
}
.wallet-provider-info { flex: 1; min-width: 0; }
.wallet-provider-name {
font-weight: 600; font-size: 0.875rem; color: var(--rs-text-primary);
}
.wallet-provider-addr {
font-size: 0.75rem; color: #34d399; font-family: monospace;
}
.wallet-connect-btn {
font-size: 0.75rem; font-weight: 600; padding: 4px 14px;
border-radius: 8px; border: 1px solid rgba(6,182,212,0.4);
background: rgba(6,182,212,0.1); color: #06b6d4; cursor: pointer;
transition: all 0.15s; font-family: inherit;
}
.wallet-connect-btn:hover { background: rgba(6,182,212,0.2); }
.wallet-connect-btn:disabled { opacity: 0.6; cursor: default; }
.wallet-empty {
text-align: center; padding: 1rem 0;
color: var(--rs-text-secondary); font-size: 0.85rem; line-height: 1.6;
}
.wallet-footer {
margin-top: 1.5rem; padding-top: 1rem;
border-top: 1px solid var(--rs-border); text-align: center;
}
.wallet-open-btn {
font-size: 0.875rem; font-weight: 600; padding: 8px 24px;
border-radius: 10px; border: none; cursor: pointer;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
transition: all 0.2s; font-family: inherit;
}
.wallet-open-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
`;