From e6588017edfb029043b39c111b007d4f4d7ee679 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 29 May 2026 08:53:15 -0400 Subject: [PATCH] Premium: fix three issues from Adam's testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Premium designs missing from the "New" tab isPreviewBroken was flagging every premium cover / disc / jewel / disc-design row as broken: those modes are in MODES_REQUIRE_IMAGE, the stub payload has no slot/disc art, so row.preview was '' → "broken". The New tab's sort puts broken rows last, so premium rows sank past the 24-card pagination cutoff and disappeared. Premium rows carry their own watermarked-JPEG preview inside the envelope and always show it on the card, so they should never count as "broken." isPreviewBroken now short-circuits on row.premium and returns false unconditionally. 2) Upvote button felt unresponsive The reaction handler awaited publishToRelays before changing the button text — on slow relays that's a few seconds of no visible feedback, so the click looked like it did nothing. Now it optimistically swaps to "⋯" + disables the button as soon as you click, then either commits to "▲ N" on success or rolls back to the original text on failure. No more "did it work?" 3) My Designs → Purchased tab loaded inconsistently Previously force-refetched the vault from relays every time the tab opened, blocking the render on the slowest relay in the pool. If the fetch failed, the whole wall went to an error placeholder. Now: - If we have an in-memory _vaultCache from the current session, render those cards IMMEDIATELY (sync, before the await), then re-fetch in the background and re-render with the latest. Feels instant in the common case where the user just unlocked something. - Top-of-list refresh button so the user can force a relay re-query on demand (useful after unlocking on another device). - Items missing their per-item event (the split-vault _missing flag) render at .55 opacity with a click-to-toast explanation instead of silently failing. - On refresh failure, keep showing the cached cards — don't blow them away just because relays timed out — and surface a toastError with the actual reason. Tested via CDP: - Premium cover row → isPreviewBroken = false ✓ - Reaction button: click → text becomes "⋯", disabled = true. On simulated failure response → rolls back to "▲", re-enabled. - Existing vault round-trip + hydrate + Purchased-tab regression still passes. Co-Authored-By: Claude Opus 4.7 --- src/index.html | 98 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 18 deletions(-) diff --git a/src/index.html b/src/index.html index c9fe53e..9e160cb 100644 --- a/src/index.html +++ b/src/index.html @@ -6117,30 +6117,76 @@ return row; } - async function loadPurchasedDesignsIntoWall(wall) { + function _renderVaultItemsIntoWall(wall, items, hadError = false, opts = {}) { + wall.innerHTML = ''; + // Top toolbar: count + refresh button. Refresh forces a relay re- + // query in case the user just bought something on another device. + const topBar = document.createElement('div'); + topBar.style.cssText = 'grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 8px;font-size:.78rem;color:var(--muted)'; + const count = document.createElement('span'); + count.textContent = items.length + ? `${items.length} unlocked design${items.length === 1 ? '' : 's'}` + : (hadError ? 'Vault load failed' : 'No premium designs unlocked yet'); + const refreshBtn = document.createElement('button'); + refreshBtn.type = 'button'; + refreshBtn.textContent = '↻ Refresh'; + refreshBtn.title = 'Re-query relays for the latest vault'; + refreshBtn.addEventListener('click', () => loadPurchasedDesignsIntoWall(wall, { force: true })); + topBar.append(count, refreshBtn); + wall.appendChild(topBar); + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'hint'; + empty.style.cssText = 'grid-column:1/-1'; + empty.textContent = hadError + ? 'Couldn’t load your purchase vault from relays. Try refresh.' + : 'Visit the feed and zap a premium template to start your collection.'; + wall.appendChild(empty); + return; + } + for (const item of items) { + const row = _rowFromVaultItem(item); + const card = buildFeedCard(row, () => { + if (item._missing) { + toastError('This vault entry is missing its per-item event on relays. Try Refresh, or re-unlock from the feed.'); + return; + } + try { loadProjectFromText(JSON.stringify(row.payload)); } + catch (err) { toastError('Could not open: ' + (err?.message || err)); return; } + $('nostrModal').classList.remove('open'); + }); + if (item._missing) card.style.opacity = '0.55'; + wall.appendChild(card); + } + } + + async function loadPurchasedDesignsIntoWall(wall, opts = {}) { if (!_supportsVault()) { wall.innerHTML = '
Your Nostr extension doesn’t support encryption (NIP-44 / NIP-04), so the purchase vault is unavailable.
'; return; } - wall.innerHTML = '
Loading purchased designs…
'; + // Render the in-memory cache immediately if we have one — feels + // instant. Then re-fetch in the background and re-render with the + // latest from relays. If we have no cache, show a placeholder while + // the first fetch happens. + const cached = _vaultCache; + if (cached?.items?.length) { + _renderVaultItemsIntoWall(wall, cached.items, false); + } else { + wall.innerHTML = '
Loading purchased designs…
'; + } try { - const vault = await loadPurchaseVault({ force: true }); - const items = vault?.items || []; - wall.innerHTML = ''; - if (!items.length) { - wall.innerHTML = '
No premium designs unlocked yet. Visit the feed and zap a premium template to start your collection.
'; - return; - } - for (const item of items) { - const row = _rowFromVaultItem(item); - wall.appendChild(buildFeedCard(row, () => { - try { loadProjectFromText(JSON.stringify(row.payload)); } - catch (err) { toastError('Could not open: ' + (err?.message || err)); return; } - $('nostrModal').classList.remove('open'); - })); - } + const vault = await loadPurchaseVault({ force: opts.force !== false }); + _renderVaultItemsIntoWall(wall, vault?.items || [], false); } catch (err) { - wall.innerHTML = `
Failed to load purchase vault: ${escapeHtml(err?.message || String(err))}
`; + console.warn('[purchased tab] load failed', err); + // Keep whatever we had cached on the wall; only fall back to the + // error placeholder if we never had any cache. + if (!cached?.items?.length) { + _renderVaultItemsIntoWall(wall, [], true); + } else { + if (typeof toastError === 'function') toastError('Vault refresh failed: ' + (err?.message || err)); + } } } // ---- end Purchase vault ------------------------------------------------ @@ -18375,6 +18421,13 @@ function isPreviewBroken(row) { if (!row) return false; + // Premium rows carry their own watermarked-JPEG preview embedded in + // the envelope (or the cached decrypted payload's mini-preview). + // They never count as "broken" — without this exception, premium + // cover/disc/jewel rows (whose synthesised stub has no slot art so + // row.preview is '') would always sort to the back of the New tab + // and get hidden past the chunk-size cutoff. + if (row.premium) return false; const preview = row.preview; // No URL at all + this mode expects one → effectively a missing image. if (!preview && MODES_REQUIRE_IMAGE.has(row.mode)) return true; @@ -21061,11 +21114,20 @@ btn.title = 'React to this template on Nostr'; btn.addEventListener('click', async e => { e.stopPropagation(); + if (btn.disabled || btn.classList.contains('reacted')) return; + // Optimistic update so the button feels responsive even when the + // relay publish takes a few seconds. We roll back on failure. + const prevText = btn.textContent; + btn.disabled = true; + btn.textContent = '⋯'; const ok = await reactToNostrEvent(row.e.id, row.e.pubkey); + btn.disabled = false; if (ok) { row.reactions = _feedCache.reactions[row.e.id] || 1; btn.textContent = `▲ ${row.reactions}`; btn.classList.add('reacted'); + } else { + btn.textContent = prevText; } }); return btn;