Layer extract: publish embedded design layer as its own linked event

User-triggered conversion of an embedded design layer into a fresh
published kind-30078 event under the user's pubkey, replaced by a
linked layer pointing at the new event. Spec §10.3 / §11.4 — when
you decide a sub-design is reusable, one click extracts it without
re-importing by hand.

UI: new "🔗 Publish + link" button in the embedded design-layer
options panel (alongside Edit and Replace from file). Hidden for
linked layers since they're already what extract produces.

Flow:
1. Confirm with copy that warns about publishing under the user's
   identity (per spec §10.3 — only do this for designs you made).
2. Clone the layer's payload, override payload.meta.title with the
   layer's name, keep host category/language so the new event
   discovers into the same bucket.
3. Strip linked-design ephemera + walk the linked tree for
   attribution p-tags + link tags, same as the host-publish path.
4. Mint d-tag = "extracted:<mode>:<slug>:<rand>" so it can't
   collide with the user's regular published designs.
5. Sign + publish via publishCommunityEvent.
6. Mutate the layer in place: design becomes { kind: 'linked',
   ref, mode, pinnedEventId: null, slideIndex: 0 for decks }, name
   gets the 🔗 prefix, push history.
7. Seed both resolver caches from the signed event via the new
   linkedDesigns.seed(spec, sourceEvent) method — the layer paints
   from cache instantly without waiting for relay propagation.

linkedDesigns.seed reuses _entryFromEvent so vault / premium /
private detection runs identically to a fresh fetch. Disk-cache
write happens too so a reload also paints instantly.

Group extraction is a natural follow-up — a group of layers would
publish as a single customart-mode payload sized to the group's
bbox. Not in this commit; ship single-layer first.
This commit is contained in:
thePR0M3TH3AN committed 2026-05-31 22:21:37 -04:00
1 parent 5f2f057e52
commit 77d35e7bea
1 file changed
+125 -2
+125 -2
View File
@@ -3377,7 +3377,10 @@
<button type="button" id="editDesignLayerBtn">Edit embedded design</button>
<button type="button" id="replaceDesignLayerBtn">Replace from file</button>
</div>
<p class="hint" style="font-size:.72rem;margin:4px 0 0">Design objects keep the imported design editable inside this layer. Double-clicking the layer opens it too.</p>
<div class="buttonRow">
<button type="button" id="publishLinkDesignLayerBtn" title="Publish this layer's payload as its own Nostr design and replace this layer with a link to it. Future edits to that design will propagate to every host that links it.">🔗 Publish + link</button>
</div>
<p class="hint" style="font-size:.72rem;margin:4px 0 0">Design objects keep the imported design editable inside this layer. Double-clicking the layer opens it too. Publish + link extracts this layer into its own reusable Nostr design.</p>
</div>
<div id="linkedDesignLayerControls" style="display:none">
@@ -6853,7 +6856,22 @@
function keyFor(linkSpec) { return _cacheKey(linkSpec || {}); }
return { resolveLinked, walkLinkedTree, walkContributors, clearCache, peek, keyFor, _parseAddressCoord, _cacheKey };
// Populate both caches from a freshly-published source event
// so a layer that was just extracted-and-linked paints
// instantly without waiting for relay propagation. Returns
// the entry that was cached so callers can verify the parse
// succeeded.
async function seed(spec, sourceEvent) {
const key = _cacheKey(spec || {});
if (!key || !sourceEvent?.content) return null;
const entry = await _entryFromEvent(sourceEvent);
if (!entry) return null;
cache.set(key, { ...entry, fetchedAt: _now() });
_diskWrite(key, sourceEvent);
return entry;
}
return { resolveLinked, walkLinkedTree, walkContributors, clearCache, peek, keyFor, seed, _parseAddressCoord, _cacheKey };
})();
// Walk the host's linked tree to collect (a) distinct contributor
@@ -11662,6 +11680,110 @@
toastSuccess('Unlinked — source payload snapshotted into this layer.');
}
// Publish an embedded design layer as its own kind-30078 event,
// then replace the local layer with a linked layer pointing at
// the new event. Lets a user extract a reusable component
// (icon, sub-design, slide etc.) from a host design without
// re-importing it elsewhere by hand. Spec §10.3 — the new event
// is published under the *current user's* identity since we
// can't sign on behalf of whoever originally authored the
// embedded content; warn in the confirm copy.
async function publishSelectedLayerAsLinkedDesign() {
const layer = selectedLayer();
if (!layer || layer.type !== 'design') return;
if (layer.design?.kind === 'linked') {
toastError('This layer is already linked.');
return;
}
const myPubkey = state.community?.myPubkey || '';
if (!myPubkey) {
toastError('Sign in with Nostr first (Profile → Sign in).');
return;
}
const sourcePayload = layer.design?.payload || null;
if (!sourcePayload || !isDesignPayload(sourcePayload)) {
toastError('This layer has no design payload to publish.');
return;
}
const baseTitle = (layer.name || '').replace(/^🔗\s*/, '').trim()
|| designObjectTitle(sourcePayload)
|| 'Extracted layer';
const ok = confirm(
`Publish "${baseTitle}" as its own Nostr design and replace this layer with a link to it?\n\n` +
`• Publishes under your Nostr identity. Only do this for designs you made — published content is credited to your pubkey.\n` +
`• Edits to the new design propagate to every host that links it.\n` +
`• To bring the design back inline, use "Unlink to embed" on the linked layer.`
);
if (!ok) return;
const payload = clonePayload(sourcePayload);
// Carry the host's category / language / language defaults so
// the extracted event lands in the same discovery bucket; the
// title is the layer's own.
payload.meta = {
...(state.meta || {}),
...(payload.meta || {}),
title: baseTitle,
};
const mode = normalizeTemplateMode(payload) || layer.design.mode || 'cover';
stripLinkedDesignEphemera(payload);
let attributionTags = [];
try {
const built = await _buildLinkedAttributionTags(payload, myPubkey);
attributionTags = [...built.contributorTags, ...built.linkTags];
} catch (err) {
console.warn('[layer-extract] attribution build failed', err);
}
const slug = String(baseTitle).toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'layer';
const dTag = `extracted:${mode}:${slug}:${Math.random().toString(36).slice(2, 8)}`;
const typeTag = typeTagForMode(mode);
const tags = [
['d', dTag],
...getTemplateTags(payload, typeTag),
...attributionTags,
];
const evt = {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags,
content: JSON.stringify(payload),
};
const publishingToast = toast('Publishing extracted design…', { duration: 30000 });
let signed;
try {
signed = await publishCommunityEvent(evt);
} catch (err) {
publishingToast?.dismiss?.();
toastError(err?.message || 'Could not publish the extracted design.');
return;
}
publishingToast?.dismiss?.();
if (!signed?.id) {
toastError('Publish succeeded but the relay did not return the event id.');
return;
}
const ref = `30078:${myPubkey}:${dTag}`;
pushHistory('extract layer as linked design');
layer.name = '🔗 ' + baseTitle;
layer.design = {
kind: 'linked',
ref,
mode,
pinnedEventId: null,
...(mode === 'deck' ? { slideIndex: 0 } : {}),
};
// Seed the resolver caches from the just-published event so
// the layer paints instantly without waiting on relay
// propagation.
try { await linkedDesigns.seed({ ref, pinnedEventId: null }, signed); } catch (err) {
console.warn('[layer-extract] cache seed failed', err);
}
render();
toastSuccess('🔗 Layer published as its own design and linked here.');
}
function handleDesignLayerFileImport(event) {
const file = event.target.files && event.target.files[0];
event.target.value = '';
@@ -25929,6 +26051,7 @@
if (l && l.type === 'design') beginDesignObjectEdit(l.id);
});
$('replaceDesignLayerBtn')?.addEventListener('click', replaceSelectedDesignLayerFromFile);
$('publishLinkDesignLayerBtn')?.addEventListener('click', publishSelectedLayerAsLinkedDesign);
$('designLayerFile')?.addEventListener('change', handleDesignLayerFileImport);
$('linkedDesignRefreshBtn')?.addEventListener('click', refreshSelectedLinkedDesignLayer);
$('linkedDesignReplaceBtn')?.addEventListener('click', replaceSelectedLinkedDesignLayer);