mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-06 13:40:39 +00:00
Linked Designs P2: premium sources + locked render + unlock CTA
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.
This commit is contained in:
1 parent
d13a7016a2
commit
b592a69c20
1 file changed
+134
-3
+134
-3
@@ -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 = '<div style="font-size:1.4em">🔒</div><div>Linked premium design</div>';
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user