web: fix the space menu's Upgrade link doing nothing under popup blockers (#1322)
5 files changed,
+39
−9
changelog/2026-07-23-upgrade-link-fix.md+10 −0home/web/static/home.js+11 −0web/pages/index.html+1 −1web/static/shell.js+16 −8web/static/style.css+1 −0
diff --git a/changelog/2026-07-23-upgrade-link-fix.md b/changelog/2026-07-23-upgrade-link-fix.md| new file mode 100644 |
| index 0000000..b27fd46 |
| --- /dev/null |
| +++ b/changelog/2026-07-23-upgrade-link-fix.md |
| @@ -0,0 +1,10 @@ |
| +🤖 Fixed: the space menu's "Upgrade" item could silently do nothing. It opened |
| +the home page's checkout in a new tab via a script call, which some browsers' |
| +ad/privacy-blocker extensions treat as a popup and quietly swallow — no error, |
| +no new tab, no indication anything was blocked. It's now a real link, the same |
| +kind any ordinary "opens in a new tab" link uses, so it isn't caught by that |
| +blocking behavior. |
| + |
| +Also: if a space's region has no upgrade plans to offer, the picker used to |
| +open with a silent blank gap where the plan buttons belong. It now says so |
| +plainly instead. |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index d14d959..6c42a1b 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -3026,6 +3026,17 @@ function renderUpgrade(s) { |
| const rates = fxSnapshot && fxSnapshot.rates; |
| const box = $("upgrade-tiers"); |
| box.textContent = ""; |
| + // The listing said this space isn't paid, but its region advertised no |
| + // tiers to sell (billing not configured there, or momentarily unreachable) |
| + // — say so plainly rather than leaving a silent gap where the plan |
| + // buttons belong (openUpgrade only gets here once ownership + free-tier |
| + // are already confirmed, so the sheet itself is warranted). |
| + if (!billingTiers.length) { |
| + upgradeStatus("Upgrading isn't available for this space right now.", "error"); |
| + $("upgrade-fx").classList.add("hidden"); |
| + $("upgrade-cap").textContent = ""; |
| + return; |
| + } |
| let anyApprox = false; |
| for (const t of billingTiers) { |
| const view = tierForBand(t, selectedBand); |
diff --git a/web/pages/index.html b/web/pages/index.html| index bdb0d3d..7bd9682 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -157,7 +157,7 @@ |
| platform with a scheme receiver today; iOS follows. --> |
| <button class="menu-item" id="menu-open-app" role="menuitem" hidden>Open in the Eleven app</button> |
| <button class="menu-item" id="menu-rename" role="menuitem">Edit profile</button> |
| - <button class="menu-item" id="menu-upgrade" role="menuitem" hidden>Upgrade</button> |
| + <a class="menu-item" id="menu-upgrade" role="menuitem" hidden target="_blank" rel="noopener">Upgrade</a> |
| <button class="menu-item danger" id="menu-leave" role="menuitem">Leave</button> |
| </div> |
| </div> |
diff --git a/web/static/shell.js b/web/static/shell.js| index 36e11c4..102f5f4 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -888,12 +888,15 @@ let nativeMenuSeq = 0; |
| const nativeMenuPending = new Set(); |
| // Pop up `menuEl`'s items as a native menu, anchored under `anchorEl`. Skips |
| -// items the web layer has hidden (inline display:none — group-only entries, the |
| -// notifications toggle where push is unusable), so the native menu matches |
| -// exactly what the HTML one would show. |
| +// items the web layer has hidden — inline display:none (group-only entries, |
| +// the notifications toggle where push is unusable) or the `hidden` attribute |
| +// (Open in the app, Upgrade) — so the native menu matches exactly what the |
| +// HTML one would show. Missing the `hidden` half of this used to leak |
| +// Upgrade into the native NSMenu, which the caller deliberately keeps out of |
| +// native for App Store rules (see the Upgrade wiring below). |
| function openNativeMenu(menuEl, anchorEl) { |
| const items = [...menuEl.querySelectorAll(".menu-item")] |
| - .filter((el) => el.style.display !== "none") |
| + .filter((el) => el.style.display !== "none" && !el.hidden) |
| .map((el) => ({ id: el.id, label: el.textContent.trim(), danger: el.classList.contains("danger") })); |
| if (!items.length) return; |
| const token = ++nativeMenuSeq; |
| @@ -2838,10 +2841,15 @@ function renderApp() { |
| document.documentElement.classList.contains("native") || |
| !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.lchat); |
| if (window.__entitlement === "free" && window.__homeOrigin && !inNativeWrapper) { |
| - document.getElementById("menu-upgrade").hidden = false; |
| - wireItem("menu-upgrade", () => |
| - window.open(window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host), "_blank"), |
| - ); |
| + // A real <a> (not a button + window.open): ad/privacy-blocker extensions |
| + // commonly swallow script-initiated window.open() popups silently — no |
| + // console error, no address-bar indicator — while leaving ordinary link |
| + // navigation alone. closeMenu() runs as a plain side effect; it never |
| + // calls preventDefault, so the browser still follows href natively. |
| + const upgradeLink = document.getElementById("menu-upgrade"); |
| + upgradeLink.hidden = false; |
| + upgradeLink.href = window.__homeOrigin + "/#upgrade=" + encodeURIComponent(location.host); |
| + upgradeLink.onclick = closeMenu; |
| } |
| // Demo countdown band (docs/demo-spaces.md): a demo space wears its 24-hour |
diff --git a/web/static/style.css b/web/static/style.css| index aef59d4..493338d 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -479,6 +479,7 @@ select:disabled { opacity: 0.5; cursor: default; } |
| padding: 10px 12px; |
| font-weight: 500; |
| white-space: normal; |
| + text-decoration: none; /* menu-upgrade is an <a>; buttons ignore this */ |
| } |
| .menu-item:hover { background: var(--bubble-them); } |
| /* Menu rows read danger by text colour alone — the red pill border |