Skip to content

Commit a1232fc

Browse files
clawdbot-glitch003glitch003claude
authored
Add wallet pill menu with copy + disconnect to dashboard (#476)
* Add wallet pill menu with copy + disconnect to dashboard Turn the ChainSecured wallet address pill in the dashboard topbar into a popover menu. Clicking it now opens a small dropdown with "Copy address" and "Disconnect wallet" instead of copying directly. Disconnect tears down the EIP-1193/WalletConnect connection and signs out. Closes on outside-click or Escape, mirroring the existing mode-badge popover. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix wallet menu copy feedback + listener cleanup Address two findings from adversarial review of the wallet pill menu: - Copy feedback was invisible: close() ran synchronously after copy, hiding the button before copyToClipboard's "Copied!" label could show. Delay the close so the feedback is visible. - Outside-click / Escape listeners could leak: the toggle-closed path and a renderModeBadge() topbar rebuild while the menu was open left stale capture listeners holding detached-node closures. Bind them to an AbortController, tracked at module scope, and abort on close and on the next rebuild. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chris Cassano <chris@litprotocol.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d0ec58b commit a1232fc

2 files changed

Lines changed: 156 additions & 4 deletions

File tree

lit-static/dapps/dashboard/auth.js

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,24 @@ export function logOut() {
131131
updateAuthUI();
132132
}
133133

134+
/**
135+
* Disconnect the connected browser wallet, then sign out. In ChainSecured mode
136+
* the wallet IS the session, so tearing down the EIP-1193 / WalletConnect
137+
* connection and clearing the session are the same user intent. The
138+
* `onWalletChange` watcher also fires `logOut()` on disconnect, but we call it
139+
* directly too so sign-out is synchronous (and correct if no live wallet
140+
* connection is attached, e.g. after a page reload).
141+
*/
142+
async function disconnectWallet() {
143+
try {
144+
const { disconnect } = await import('../../wallet_connect.js');
145+
disconnect();
146+
} catch (e) {
147+
console.warn('[auth] wallet disconnect failed:', e);
148+
}
149+
logOut();
150+
}
151+
134152
/** Invalidate the cached SDK client; next getClient() rebuilds fresh. */
135153
export function resetClient() {
136154
_clientInstance = null;
@@ -281,6 +299,11 @@ let _clientInstance = null;
281299
let _clientBaseUrl = null;
282300
let _clientMode = null;
283301

302+
// AbortController for the currently-open wallet pill menu's document listeners.
303+
// Tracked at module scope so a topbar rebuild (renderModeBadge) can abort a
304+
// prior render's listeners before its nodes are replaced. See wireWalletPillMenu.
305+
let _walletMenuAbort = null;
306+
284307
// Fires after every successful client method call (Proxy below). Used by
285308
// billing.js to refresh the credit balance display after any API activity
286309
// (NODE-4971). Registered via setOnApiCallSuccess() from initBilling().
@@ -547,7 +570,13 @@ function renderModeBadge() {
547570
if (isChainSecured) {
548571
const wallet = getChainSecuredWallet();
549572
const trunc = `${wallet.slice(0, 6)}\u2026${wallet.slice(-4)}`;
550-
pillHtml = ` <button type="button" class="topbar-wallet-pill" id="topbar-wallet-pill" title="Copy wallet address" data-wallet="${escapeHtml(wallet)}">${escapeHtml(trunc)}</button>`;
573+
pillHtml = ` <span class="topbar-wallet-wrap">
574+
<button type="button" class="topbar-wallet-pill" id="topbar-wallet-pill" aria-haspopup="menu" aria-expanded="false" title="Wallet options" data-wallet="${escapeHtml(wallet)}">${escapeHtml(trunc)}<span class="topbar-wallet-chevron" aria-hidden="true">\u25be</span></button>
575+
<div class="topbar-wallet-menu" id="topbar-wallet-menu" role="menu" aria-label="Wallet options" hidden>
576+
<button type="button" class="topbar-wallet-menu-item" id="topbar-wallet-copy" role="menuitem">Copy address</button>
577+
<button type="button" class="topbar-wallet-menu-item topbar-wallet-menu-danger" id="topbar-wallet-disconnect" role="menuitem">Disconnect wallet</button>
578+
</div>
579+
</span>`;
551580
}
552581
host.innerHTML = `<span class="topbar-mode-badge-wrap">
553582
<button type="button" class="topbar-mode-badge" id="topbar-mode-badge" aria-haspopup="dialog" aria-expanded="false">${escapeHtml(modeLabel)}</button>
@@ -577,11 +606,73 @@ function renderModeBadge() {
577606
}
578607
});
579608
}
609+
wireWalletPillMenu();
610+
}
611+
612+
/**
613+
* Wire the ChainSecured wallet pill (topbar-left) as a popover menu:
614+
* "Copy address" + "Disconnect wallet". Mirrors the mode-badge popover's
615+
* open/close behavior (outside-click + Escape close). No-op when the pill is
616+
* absent (API mode / unauthenticated).
617+
*
618+
* The outside-click / Escape handlers are bound to an AbortController so they
619+
* are always torn down on close — including the toggle-closed path and the
620+
* case where `renderModeBadge()` rebuilds the topbar (replacing these nodes)
621+
* while the menu is still open. Without that, a stale capture listener would
622+
* linger holding a detached-node closure until the next document click.
623+
*/
624+
function wireWalletPillMenu() {
580625
const pill = document.getElementById('topbar-wallet-pill');
581-
if (pill) {
582-
pill.addEventListener('click', async () => {
626+
const menu = document.getElementById('topbar-wallet-menu');
627+
const copyBtn = document.getElementById('topbar-wallet-copy');
628+
const disconnectBtn = document.getElementById('topbar-wallet-disconnect');
629+
// Abort any listeners left over from a prior render's open menu before the
630+
// old nodes (which this rebuild replaced) leak their closures.
631+
if (_walletMenuAbort) { _walletMenuAbort.abort(); _walletMenuAbort = null; }
632+
if (!pill || !menu) return;
633+
634+
let ctrl = null;
635+
const close = () => {
636+
menu.hidden = true;
637+
pill.setAttribute('aria-expanded', 'false');
638+
if (ctrl) {
639+
ctrl.abort();
640+
if (_walletMenuAbort === ctrl) _walletMenuAbort = null;
641+
ctrl = null;
642+
}
643+
};
644+
const open = () => {
645+
menu.hidden = false;
646+
pill.setAttribute('aria-expanded', 'true');
647+
ctrl = new AbortController();
648+
_walletMenuAbort = ctrl;
649+
const { signal } = ctrl;
650+
document.addEventListener('click', (ev) => {
651+
if (!pill.contains(ev.target) && !menu.contains(ev.target)) close();
652+
}, { capture: true, signal });
653+
document.addEventListener('keydown', (ev) => {
654+
if (ev.key === 'Escape') close();
655+
}, { capture: true, signal });
656+
};
657+
658+
pill.addEventListener('click', (ev) => {
659+
ev.stopPropagation();
660+
if (menu.hidden) open(); else close();
661+
});
662+
663+
if (copyBtn) {
664+
copyBtn.addEventListener('click', async () => {
583665
const { copyToClipboard } = await import('./ui-utils.js');
584-
await copyToClipboard(pill.dataset.wallet, pill);
666+
await copyToClipboard(pill.dataset.wallet, copyBtn);
667+
// Keep the menu open briefly so the button's "Copied!" feedback is
668+
// visible (copyToClipboard restores the label at 1500ms), then close.
669+
setTimeout(close, 1200);
670+
});
671+
}
672+
if (disconnectBtn) {
673+
disconnectBtn.addEventListener('click', () => {
674+
close();
675+
disconnectWallet();
585676
});
586677
}
587678
}

lit-static/dapps/dashboard/styles.css

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,67 @@ body:not(.is-chainsecured) .is-chainsecured-only {
915915
background: rgba(255, 255, 255, 0.06);
916916
}
917917

918+
.topbar-wallet-wrap {
919+
position: relative;
920+
display: inline-block;
921+
}
922+
923+
.topbar-wallet-chevron {
924+
font-size: 0.6rem;
925+
margin-left: 0.3rem;
926+
opacity: 0.7;
927+
}
928+
929+
.topbar-wallet-menu {
930+
position: absolute;
931+
top: 100%;
932+
left: 0;
933+
margin-top: 0.35rem;
934+
min-width: 180px;
935+
background: var(--card-bg);
936+
border: 1px solid var(--border);
937+
border-radius: var(--radius);
938+
box-shadow: var(--shadow), 0 10px 25px rgba(0, 0, 0, 0.15);
939+
padding: 0.35rem 0;
940+
z-index: 100;
941+
}
942+
943+
.topbar-wallet-menu-item {
944+
display: block;
945+
width: 100%;
946+
padding: 0.5rem 1rem;
947+
font-size: 0.875rem;
948+
text-align: left;
949+
background: none;
950+
border: none;
951+
color: var(--text);
952+
cursor: pointer;
953+
transition: background 0.15s;
954+
font-family: inherit;
955+
}
956+
957+
.topbar-wallet-menu-item:hover {
958+
background: rgba(0, 0, 0, 0.05);
959+
}
960+
961+
[data-theme="dark"] .topbar-wallet-menu-item:hover {
962+
background: rgba(255, 255, 255, 0.08);
963+
}
964+
965+
.topbar-wallet-menu-danger {
966+
color: var(--danger);
967+
font-weight: 500;
968+
}
969+
970+
.topbar-wallet-menu-danger:hover {
971+
background: rgba(220, 38, 38, 0.08);
972+
color: var(--danger);
973+
}
974+
975+
[data-theme="dark"] .topbar-wallet-menu-danger:hover {
976+
background: rgba(248, 113, 113, 0.12);
977+
}
978+
918979
.abi-drift-banner {
919980
padding: 0.75rem 1rem;
920981
margin: 0 0 1rem;

0 commit comments

Comments
 (0)