From b592a69c20dbca37f9b5869a98b4c0f84d1b46d7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 31 May 2026 21:56:00 -0400 Subject: [PATCH] Linked Designs P2: premium sources + locked render + unlock CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LINKED_DESIGNS_FEATURE.md ยง12.1 โ€” Phase 1 silently dropped premium / encrypted sources because the resolver's JSON.parse hit an envelope shape and the renderer treated payload=null as "source missing." Phase 2 detects premium events, gates rendering on the buyer's purchase-vault entry, and surfaces an inline unlock CTA when the viewer hasn't bought yet. Resolver changes (_fetchPayload): - Detect premium events via isPremiumEvent() on the source event. - Look up findUnlockedEntry(ev.id, addressCoord); on hit, return the cached decrypted payload as if it were public. - For self-authored sources, run Premium.decryptPayload locally (soft-gate KDF is deterministic from public material) and mirror the unlock into the vault via markUnlocked so My Designs and the feed see it too. - Otherwise return { payload: null, premium: { state: 'locked', envelope, addressCoord } } so the renderer can paint the locked UI without re-fetching. Renderer changes (renderDesignObjectIntoElement linked branch): - New renderLockedPremium helper paints the watermarked preview JPEG (from envelope.preview.dataUrl) at .45 opacity behind a semi-transparent overlay with ๐Ÿ”’ + "Linked premium design" + an "โšก Unlock for X sats" button. - Button awaits openUnlockConfirm with a synthetic row built from the source event; after it resolves, checks the vault and if the entry landed, clears the resolver cache for this layer's key and re-renders. The resolver re-fetches and the next paint shows the decrypted preview. - Sync peek hit handles cached.premium.state === 'locked'; async path handles entry.premium.state === 'locked'. Locked sources still contribute to attribution (sourceEvent.pubkey is known regardless of payload visibility), but walkLinkedTree descends no further into them โ€” encrypted contents are opaque to us, so nested credits stop at the locked boundary. Acceptable Phase 2 limitation. Private (visibility) linked designs are NOT in this commit; adding on top as P2c if Adam wants. --- src/index.html | 137 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/src/index.html b/src/index.html index 0b19ae4..c859ad4 100644 --- a/src/index.html +++ b/src/index.html @@ -6549,10 +6549,70 @@ if (!ev?.content || ev.content.length > LINKED_DESIGN_MAX_EVENT_BYTES) { return null; } - let payload; - try { payload = JSON.parse(ev.content); } + let parsed; + try { parsed = JSON.parse(ev.content); } catch { return null; } - return { payload, sourceEvent: ev, contentBytes: ev.content.length }; + // Premium sources (LINKED_DESIGNS_FEATURE.md ยง12.1) carry an + // encrypted envelope instead of a raw payload. Access is + // gated by the buyer's purchase-vault entry โ€” NOT the + // decryption itself, since the soft-gate KDF is deterministic + // from public material (any browser could technically + // decrypt). Check the vault first; if there's no entry, + // try a self-authored decrypt; else return a locked entry + // the renderer can show with an unlock CTA. + if (typeof isPremiumEvent === 'function' && isPremiumEvent(ev)) { + const envelope = parsed; + const addressCoord = typeof premiumAddressCoord === 'function' ? premiumAddressCoord(ev) : ''; + const vaultEntry = typeof findUnlockedEntry === 'function' ? findUnlockedEntry(ev.id, addressCoord) : null; + if (vaultEntry?.payload) { + return { + payload: vaultEntry.payload, + sourceEvent: ev, + contentBytes: ev.content.length, + premium: { state: 'unlocked', envelope, addressCoord }, + }; + } + // Self-authored sources auto-decrypt โ€” soft-gate KDF is + // deterministic from public material, so the host author + // can always decrypt their own premium designs. + const myPubkey = state.community?.myPubkey || ''; + if (myPubkey && ev.pubkey === myPubkey && typeof Premium?.decryptPayload === 'function') { + try { + const decrypted = await Premium.decryptPayload(envelope, { + creatorPubkey: ev.pubkey, + dTag: typeof premiumEventDTag === 'function' ? premiumEventDTag(ev) : '', + schemaVersion: envelope?.payloadSchemaVersion || 5, + }); + if (decrypted) { + // Mirror the unlock into the vault so the rest of + // the app sees it too (My Designs, feed, etc). + if (typeof markUnlocked === 'function') { + markUnlocked(ev.id, { + source: 'self-authored-link', + address: addressCoord, + payload: decrypted, + previewJpegDataUrl: envelope?.preview?.dataUrl || undefined, + }); + } + return { + payload: decrypted, + sourceEvent: ev, + contentBytes: ev.content.length, + premium: { state: 'unlocked', envelope, addressCoord }, + }; + } + } catch (err) { + console.warn('[linked-designs] self-authored premium decrypt failed', err); + } + } + return { + payload: null, + sourceEvent: ev, + contentBytes: ev.content.length, + premium: { state: 'locked', envelope, addressCoord }, + }; + } + return { payload: parsed, sourceEvent: ev, contentBytes: ev.content.length }; } // Public: resolve a single linked layer's spec ({ ref, pinnedEventId }). @@ -10998,6 +11058,67 @@ } }; + // Locked-premium paint helper (LINKED_DESIGNS_FEATURE.md ยง12.1). + // Shows the watermarked preview JPEG from the envelope + a + // semi-transparent lock overlay + an inline unlock CTA. After + // a successful unlock the vault has an entry; we clear the + // resolver cache and trigger a global render so the layer + // re-resolves into the decrypted payload. + const renderLockedPremium = (entry) => { + fitWrap.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#111'; + const previewUrl = entry?.premium?.envelope?.preview?.dataUrl; + if (previewUrl) { + const img = document.createElement('img'); + img.src = previewUrl; + img.alt = 'Linked premium preview'; + img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;opacity:.45;pointer-events:none'; + wrap.appendChild(img); + } + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:6px;padding:8px;text-align:center;font-size:.74rem;line-height:1.3;color:#fff;background:rgba(0,0,0,.55);border-radius:6px;max-width:90%;pointer-events:auto'; + overlay.innerHTML = '
๐Ÿ”’
Linked premium design
'; + const min = (typeof getZapMin === 'function' ? getZapMin(entry.sourceEvent) : null) || 100; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = `โšก Unlock for ${min} sats`; + btn.style.cssText = 'background:#fbbf24;color:#1f2937;border:none;border-radius:4px;padding:5px 10px;font-weight:600;cursor:pointer;font-size:.72rem'; + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + e.preventDefault(); + if (typeof openUnlockConfirm !== 'function') return; + const ev = entry.sourceEvent; + const fakeRow = { + e: ev, + title: (layer.name || '').replace(/^๐Ÿ”—\s*/, '') || 'Linked premium design', + zapGate: { gated: true, min }, + premium: { + envelope: entry.premium.envelope, + previewJpegDataUrl: entry.premium.envelope?.preview?.dataUrl || '', + decrypted: false, + address: entry.premium.addressCoord, + dTag: typeof premiumEventDTag === 'function' ? premiumEventDTag(ev) : '', + }, + }; + try { await openUnlockConfirm(fakeRow); } catch (err) { console.warn('[linked-designs] unlock failed', err); } + // Vault check โ€” openUnlockConfirm awaits all the way through + // markUnlocked + vault sync, so if it succeeded the entry is + // there now. Clear our cache for this layer's key and + // re-render; the resolver will see the vault entry and + // return the decrypted payload on the next round. + const vaultEntry = typeof findUnlockedEntry === 'function' ? findUnlockedEntry(ev.id, entry.premium.addressCoord) : null; + if (vaultEntry?.payload) { + const k = linkedDesigns.keyFor(spec); + if (k) linkedDesigns.clearCache(k); + render(); + } + }); + overlay.appendChild(btn); + wrap.appendChild(overlay); + fitWrap.appendChild(wrap); + }; + // Sync cache peek: skip the placeholder flicker for sources we // resolved earlier this session. const cached = linkedDesigns.peek(spec); @@ -11008,6 +11129,11 @@ renderResolved(cached.payload); return; } + if (cached?.premium?.state === 'locked') { + layer.design.lastResolvedAt = cached.fetchedAt; + renderLockedPremium(cached); + return; + } // No cache hit โ€” show a placeholder (or the persisted fallback // preview if the user opted in to one) and kick off the async @@ -11024,6 +11150,11 @@ (async () => { const entry = await linkedDesigns.resolveLinked(spec); if (!el.isConnected) return; + if (entry?.premium?.state === 'locked') { + layer.design.lastResolvedAt = entry.fetchedAt; + renderLockedPremium(entry); + return; + } if (!entry?.payload) { // Source missing on relays. Keep the fallback preview if // one was already drawn; otherwise show an inline note.