fixes and improvements

This commit is contained in:
thePR0M3TH3AN committed 2026-05-15 21:13:49 -04:00
1 parent 5c56a2d599
commit d39b672bf5
10 files changed
+2412 -102

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
# NIP-58 Badges Feature Plan
## Overview
Award and display Nostr badges on creator profiles in the community feed. Badges like
"Top Creator", "100 Zaps Received", and "Verified Designer" appear on template cards and
profile views. Because badges are a Nostr standard (NIP-58), they automatically sync with
and appear in other Nostr clients (Damus, Primal, Snort) — no proprietary profile system needed.
## Complexity
**Low–Moderate** — Estimated 3–5 hours
- Badge display (reading + rendering): ~2 hours
- Badge awarding (publishing badge events): ~2 hours
- Admin/auto-award logic: ~1 hour
---
## NIP-58 Primer
Three event kinds make up the badge system:
| Kind | Name | Purpose |
|------|------|---------|
| 30009 | Badge Definition | Defines a badge: name, description, image (addressable, created by issuer) |
| 8 | Badge Award | Issues a badge to specific pubkeys |
| 30008 | Profile Badges | User's curated list of badges they want displayed on their profile |
### Badge Definition (kind 30009)
```json
{
"kind": 30009,
"pubkey": "<app-issuer-pubkey>",
"tags": [
["d", "top-creator"],
["name", "Top Creator"],
["description", "Awarded to creators with 10+ published templates"],
["image", "https://casewrap.app/badges/top-creator.png", "512x512"],
["thumb", "https://casewrap.app/badges/top-creator-64.png", "64x64"]
]
}
```
### Badge Award (kind 8)
```json
{
"kind": 8,
"pubkey": "<app-issuer-pubkey>",
"tags": [
["a", "30009:<issuer-pubkey>:top-creator"],
["p", "<recipient-pubkey>", "<relay-hint>"]
]
}
```
### Profile Badges (kind 30008)
Users opt-in to displaying specific badges. The app reads this to know which badges
to show on a profile:
```json
{
"kind": 30008,
"pubkey": "<user-pubkey>",
"tags": [
["d", "profile_badges"],
["a", "30009:<issuer-pubkey>:top-creator"],
["e", "<award-event-id>"]
]
}
```
---
## Badge Catalog (Proposed)
| Badge ID | Name | Criteria | Auto-awarded? |
|----------|------|----------|---------------|
| `first-publish` | First Publish | Published first template | Yes |
| `top-creator` | Top Creator | 10+ published templates | Yes |
| `prolific` | Prolific | 50+ published templates | Yes |
| `zap-magnet` | Zap Magnet | Received 100+ zaps | Yes (via kind 9735 scan) |
| `early-adopter` | Early Adopter | Joined before a cutoff date | Manual |
| `verified-designer` | Verified Designer | Manually awarded by app admins | Manual |
App issues badges from a dedicated CaseWrap app keypair (not tied to any user's NIP-07 key).
The app keypair's pubkey and private key are stored in the app's admin config.
---
## Key Components
### 1. Badge Display on Template Cards and Profile Views
Fetch the creator's kind 30008 profile badges event, cross-reference with known badge
definitions, and render badge thumbnails:
```javascript
async function fetchProfileBadges(pubkey) {
// Query relays for kind 30008 authored by pubkey
// Returns array of badge definition a-tags the user has accepted
}
async function resolveDisplayBadges(pubkey) {
const profileBadges = await fetchProfileBadges(pubkey);
// Filter to only CaseWrap-issued badges (by app issuer pubkey)
// Return array of { id, name, thumbUrl } for known badges
}
```
Render as small badge icons (32×32) in the author row of template cards:
```
[email protected] 🏆 🎨 ⚡ · 2 days ago
```
Hovering a badge shows a tooltip with the badge name and description.
### 2. Badge Cache
Badge definitions rarely change — cache fetched definitions in `sessionStorage`:
```javascript
const BADGE_CACHE_KEY = 'casewrap-badge-cache';
function cacheBadgeDefinition(def) { /* store in sessionStorage */ }
function getCachedBadge(dTag) { /* retrieve from sessionStorage */ }
```
### 3. Auto-Award Logic (client-side trigger)
When a user publishes a template, check if they've crossed a badge threshold and award
automatically. Award logic runs after publish:
```javascript
async function checkAndAwardBadges(pubkey) {
const count = await fetchPublishedTemplateCount(pubkey);
if (count === 1) await awardBadge(pubkey, 'first-publish');
if (count === 10) await awardBadge(pubkey, 'top-creator');
if (count === 50) await awardBadge(pubkey, 'prolific');
}
```
Note: Auto-awarding requires the app's issuer private key to sign the kind 8 event.
For a fully static app this means either:
- Keeping the issuer key client-side (acceptable for low-stakes community badges), or
- Using a small serverless function as the award endpoint (one POST, no database)
### 4. Badge Award Function
```javascript
async function awardBadge(recipientPubkey, badgeId) {
const aTag = `30009:${APP_ISSUER_PUBKEY}:${badgeId}`;
const event = {
kind: 8,
pubkey: APP_ISSUER_PUBKEY,
tags: [
['a', aTag],
['p', recipientPubkey],
],
content: '',
created_at: Math.floor(Date.now() / 1000),
};
// Sign with APP_ISSUER_PRIVKEY (noble-secp256k1), publish to relays
await publishToRelays(signEvent(event, APP_ISSUER_PRIVKEY));
}
```
### 5. Badge Accept UI (Optional)
After receiving a badge, the user can add it to their kind 30008 profile badges so it
shows across all Nostr clients. Show a prompt: "You earned Top Creator! Add it to your profile?"
Clicking yes publishes/updates their kind 30008 event via NIP-07.
---
## UI Changes
- Author row on template feed cards: small badge icon row (max 3, +N overflow)
- Template detail panel: full badge list with names and descriptions on hover
- Profile section in settings: "Your Badges" list showing earned + pending badges
- Publish success toast: "🏆 You earned a new badge!" when a threshold is crossed
---
## Performance Considerations
- Fetch kind 30008 lazily — only when a card is hovered or detail panel opens
- Cache badge definitions in `sessionStorage` — definitions rarely change
- Batch profile badge fetches with other kind 0 / kind 3 queries at feed load time
- Show badges from cache immediately; refresh in background
---
## Limitations
- Badge display depends on the creator having accepted badges into their kind 30008 event
(users who never do this won't show badges even if awarded)
- Auto-award requires the app's issuer key to be available; keep it budget-appropriate
(not a treasury key) since it lives client-side
- No revocation mechanism in NIP-58 — badges cannot be taken back once awarded
---
## Future Enhancements
- User-to-user badge awards ("Community Pick" badge that any user can nominate for)
- Badge showcase page listing all CaseWrap badges and current recipients
- Integration with zap receipts to auto-award "Zap Magnet" by scanning kind 9735 events
---
## Testing Checklist
- [ ] Badge thumbnails render in author row on template cards
- [ ] Hovering badge shows tooltip with name and description
- [ ] Badge cache prevents redundant relay queries on repeated views
- [ ] `first-publish` badge is awarded on first successful template publish
- [ ] `top-creator` badge is awarded when published count reaches 10
- [ ] Kind 8 award event contains correct `a` and `p` tags
- [ ] "Add to profile" prompt publishes/updates kind 30008 via NIP-07
- [ ] Badge display respects user's kind 30008 accepted list (not all awarded badges shown)
- [ ] No badge icons appear for creators with no accepted badges
- [ ] Settings "Your Badges" panel shows earned badges correctly
+214
View File
@@ -0,0 +1,214 @@
# Collections Feature Plan (NIP-51)
## Overview
Let users organize their favorite community templates into named collections — "My Pixar Set",
"Horror Night", "Criterion Shelf" — and share them publicly via Nostr. Anyone can browse a
collection and import templates from it directly. Collections are standard NIP-51 bookmark
sets (kind 30001), so they sync across Nostr clients automatically.
## Complexity
**Low–Moderate** — Estimated 3–5 hours
---
## Nostr Primitive: NIP-51 Bookmark Sets (kind 30001)
Kind 30001 is an addressable (replaceable) event that holds a named list of references.
For template collections, each list item is an `a` tag pointing to a kind 30078 template:
```json
{
"kind": 30001,
"pubkey": "<user-pubkey>",
"tags": [
["d", "my-pixar-set"],
["title", "My Pixar Set"],
["description", "All my Pixar case wraps"],
["image", "https://...cover.jpg"],
["a", "30078:<creator-pubkey>:<d-tag>", "<relay-hint>"],
["a", "30078:<creator-pubkey>:<d-tag>", "<relay-hint>"]
],
"content": ""
}
```
- The `d` tag is the collection's unique identifier (slug). Updating with the same `d`
replaces the previous version — no duplicate events accumulate.
- `title`, `description`, and `image` are optional display metadata.
- Each `a` tag references one template (kind 30078 addressable event).
---
## Key Components
### 1. Creating a Collection
```javascript
async function createCollection(name, description) {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const event = {
kind: 30001,
tags: [
['d', slug],
['title', name],
['description', description],
],
content: '',
created_at: Math.floor(Date.now() / 1000),
};
await publishToRelays(await window.nostr.signEvent(event));
return slug;
}
```
### 2. Adding/Removing a Template from a Collection
Since kind 30001 is a replaceable event, adding an item means fetching the current
version, appending the new `a` tag, and re-publishing:
```javascript
async function addToCollection(collectionSlug, templateEvent) {
const aTag = buildATag(templateEvent); // '30078:<pubkey>:<d-tag>'
const current = await fetchMyCollection(collectionSlug);
const tags = current ? [...current.tags] : [['d', collectionSlug]];
if (!tags.some(t => t[0] === 'a' && t[1] === aTag)) {
tags.push(['a', aTag, '']);
}
await publishToRelays(await window.nostr.signEvent({
kind: 30001, tags, content: '', created_at: Math.floor(Date.now() / 1000),
}));
}
async function removeFromCollection(collectionSlug, templateEvent) {
const aTag = buildATag(templateEvent);
const current = await fetchMyCollection(collectionSlug);
if (!current) return;
const tags = current.tags.filter(t => !(t[0] === 'a' && t[1] === aTag));
await publishToRelays(await window.nostr.signEvent({
kind: 30001, tags, content: '', created_at: Math.floor(Date.now() / 1000),
}));
}
```
### 3. Fetching Collections
```javascript
async function fetchMyCollections() {
// Query kind 30001 authored by myPubkey
return await queryRelays(relays, {
kinds: [30001],
authors: [state.community.myPubkey],
}, { timeoutMs: 3000 });
}
async function fetchCollectionByAddress(pubkey, slug) {
// Fetch a specific collection by pubkey + d-tag for sharing/browsing
return await queryRelays(relays, {
kinds: [30001],
authors: [pubkey],
'#d': [slug],
}, { timeoutMs: 3000 });
}
```
### 4. Browsing a Shared Collection
Collections are shareable via a URL fragment or Nostr address (`naddr`):
```
casewrap.app/#collection/naddr1...
```
When a collection address is opened:
1. Fetch the kind 30001 event by naddr
2. Extract all `a` tags → list of template addresses
3. Fetch each kind 30078 template event
4. Render them as a mini-feed with "Import All" option
### 5. State Additions
```javascript
// Add to state:
collections: {
mine: [], // user's own kind 30001 events
viewing: null, // currently browsed collection (own or external)
}
```
---
## UI
### Template Card — "Save to Collection" Menu Item
Add to the card action menu (⋮):
```
⑃ Remix this design
📁 Save to collection ▶ [My Pixar Set]
[Horror Night]
[+ New collection…]
```
### Collections Panel (in sidebar or settings)
```
┌─────────────────────────────────────────┐
│ 📁 My Collections [+ New] │
│ │
│ My Pixar Set 8 templates [→] │
│ Horror Night 3 templates [→] │
│ Criterion Shelf 12 templates [→] │
└─────────────────────────────────────────┘
```
### Collection Detail View
Clicking a collection opens a mini-feed inside the community panel:
```
← Back My Pixar Set (8 templates)
─────────────────────────────────────
[Import All] [Share] [Edit] [Delete]
[Card] [Card] [Card] ...
```
"Share" copies the `naddr` Nostr address to the clipboard for sharing.
### Feed Cards — Collection Badge
If a template is in one of the user's collections, show a small 📁 icon on the card corner.
---
## Performance Considerations
- Kind 30001 events are small (just tags) — fast to fetch and publish
- Fetch the user's collections once at login, cache in `state.collections.mine`
- Re-fetch lazily when the collections panel is opened
- Template events inside a collection are fetched only when the collection detail is opened
---
## Limitations
- Requires NIP-07 login to create or modify collections
- Read-only browsing of other users' shared collections works without login
- Nostr clients that don't parse kind 30001 `a` tags (only `e` tags) may not display templates correctly — this is a client compatibility issue, not a bug here
- No private/encrypted collections — kind 30001 is a public event (NIP-51 has a draft for private lists but it is not widely supported)
---
## Future Enhancements
- Collaborative collections: multiple pubkeys can contribute (needs a separate coordination event)
- Collection cover image: auto-generated from the first template's preview image
- "Import All" bulk-imports every template in a collection into a local queue
- Featured collections: curated by app admins on a discovery page
- RSS/Atom feed for a collection (static export)
---
## Testing Checklist
- [ ] Creating a collection publishes a valid kind 30001 event with `d` and `title` tags
- [ ] Adding a template adds the correct `a` tag and re-publishes the event
- [ ] Removing a template removes the `a` tag without affecting others
- [ ] Duplicate adds are ignored (same `a` tag not added twice)
- [ ] Collections panel lists all user's kind 30001 events with correct counts
- [ ] Collection detail fetches and renders referenced template events
- [ ] "Share" copies a valid naddr to clipboard
- [ ] Shared collection URL loads and renders without login
- [ ] "Import All" imports each template in the collection
- [ ] 📁 badge appears on feed cards that are in a user collection
- [ ] Collections sync correctly after page reload (re-fetched from relays)
+213
View File
@@ -0,0 +1,213 @@
# QR Code Layer Feature Plan
## Overview
Add a QR code as a first-class layer type in the editor. Users can place a scannable QR
code anywhere on a case wrap or disc label — linking to the film's IMDb page, the Nostr
template event, a personal website, or any custom URL. The QR code renders live in the
editor canvas and is included in the print export.
## Complexity
**Low** — Estimated 2–3 hours
QR generation is a solved problem with a small, dependency-free library. The main work
is wiring it into the existing layer system.
---
## Library
**qrcode-generator** (or `qrcode.js`) — pure JavaScript, no dependencies, ~15 KB:
```html
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
```
Alternatively, use the browser's `canvas` API directly via **qr-creator** (5 KB ESM):
```html
<script type="module">
import QrCreator from 'https://esm.sh/qr-creator@1';
window.QrCreator = QrCreator;
</script>
```
Both render directly to a `<canvas>` element — a natural fit since the editor already
uses canvas for layout rendering.
---
## Layer Type: `qr`
Extend the existing layer system with a new `type: 'qr'` alongside the existing image
and text layer types:
```javascript
// New layer schema
{
type: 'qr',
url: 'https://imdb.com/title/tt0145487', // content to encode
size: 200, // px — width and height (always square)
x: 50, // position offset
y: 50,
fgColor: '#000000',
bgColor: '#ffffff',
bgTransparent: false,
}
```
---
## Key Components
### 1. QR Rendering Function
Generate a QR canvas and draw it into the main layout canvas at the layer's position:
```javascript
function renderQRLayer(ctx, layer) {
const offscreen = document.createElement('canvas');
offscreen.width = layer.size;
offscreen.height = layer.size;
QrCreator.render({
text: layer.url,
radius: 0, // square modules
ecLevel: 'M', // medium error correction (good balance for print)
fill: layer.fgColor,
background: layer.bgTransparent ? null : layer.bgColor,
size: layer.size,
}, offscreen);
ctx.drawImage(offscreen, layer.x, layer.y, layer.size, layer.size);
}
```
### 2. Add QR Layer UI
Add "Add QR Code" to the existing "Add Layer" menu (alongside "Add Image" and "Add Text"):
```
+ Add layer
├ Image layer
├ Text layer
└ QR Code layer ← new
```
Clicking it opens a small setup modal:
```
┌──────────────────────────────────────┐
│ Add QR Code │
│ │
│ URL to encode: │
│ [https://imdb.com/title/tt0145487 ] │
│ │
│ Quick links: │
│ [IMDb page] [This template] [Custom] │
│ │
│ Size: [200] px │
│ Colors: ■ Dark [#000000] □ Light [#ffffff] │
│ ☐ Transparent background │
│ │
│ [Cancel] [Add to design] │
└──────────────────────────────────────┘
```
### 3. Quick Link Presets
Pre-populate the URL field from context:
```javascript
const QR_PRESETS = {
imdb: () => `https://imdb.com/title/${state.metadata.imdbId}`,
template: () => `https://njump.me/${buildNaddr(state.community.myPubkey, state.publishedEventDTag)}`,
letterboxd: () => `https://letterboxd.com/film/${slugify(state.metadata.title)}`,
};
```
"This template" preset is only enabled after the user has published the template (so there
is an event address to link to).
### 4. Layer Edit Panel
When a QR layer is selected in the editor, show its properties in the layer panel:
- URL input (live re-renders QR on change)
- Size slider (64px–512px)
- Foreground / background color pickers
- Transparent background toggle (useful for dark case backgrounds)
### 5. Undo/Redo Integration
QR layer add/edit/delete should trigger `saveUndoSnapshot()` — same as all other layer
operations (already planned in UNDO_REDO_FEATURE.md).
---
## Rendering in Print Export
The QR layer renders identically in both the live preview and the print/PDF export since
both paths use the same canvas drawing code. No special print handling needed.
Recommended minimum size for print legibility:
- Disc label (small area): 80px minimum at 150 DPI → ~13mm physical
- Case wrap: 120px+ for comfortable scanning
Show a warning if the user sets size below 64px: "QR code may be too small to scan when printed."
---
## Error Handling
Some URLs produce QR codes too dense to scan reliably at small sizes:
- `qr-creator` will throw if the content is too long for the selected error correction level
- Fall back to lower error correction (`'L'`) automatically, warn the user if it still fails
```javascript
function safeRenderQR(canvas, layer) {
for (const ecLevel of ['M', 'L']) {
try {
QrCreator.render({ ...options, ecLevel }, canvas);
return;
} catch {
continue;
}
}
renderQRErrorPlaceholder(canvas, 'URL too long for QR at this size');
}
```
---
## Performance Considerations
- QR generation is synchronous and fast (<5ms for typical URLs)
- Offscreen canvas is created and discarded per render — no memory leak
- Live re-render on URL change is instant; no debounce needed
---
## Limitations
- QR codes only encode text/URLs — no binary data
- Very long URLs (>200 chars) produce dense QR codes that may not scan reliably at small print sizes; recommend URL shorteners for long links
- Transparent background only works if the printing surface is light-colored
---
## Future Enhancements
- Rounded module style (aesthetic option for modern-looking QR codes)
- Center logo/image inside QR code (common branding pattern — works with high EC level)
- NFC tag companion: generate NFC URI payload alongside QR (informational only)
- Batch QR: auto-place IMDb QR on all designs in a session
---
## Testing Checklist
- [ ] "Add QR Code" option appears in the Add Layer menu
- [ ] QR setup modal opens and pre-populates IMDb quick link when imdbId is set
- [ ] QR code renders correctly in the editor canvas at added position
- [ ] Live re-render triggers when URL is edited in the layer panel
- [ ] Size slider updates QR dimensions in real time
- [ ] Foreground/background color changes reflect immediately
- [ ] Transparent background renders correctly over a dark case wrap
- [ ] QR layer is included in the print/PDF export
- [ ] Sub-64px size shows legibility warning
- [ ] Overly long URL falls back to lower EC level gracefully
- [ ] QR add/edit/delete triggers undo snapshot
- [ ] QR layer saves and restores correctly in project JSON export/import
- [ ] Rendered QR code is actually scannable (test with phone camera)
+162
View File
@@ -0,0 +1,162 @@
# Template Forking / Remix Feature Plan
## Overview
Allow users to import any community template as a starting point for their own design,
with automatic attribution to the original creator baked into the Nostr event. Forks are
discoverable — anyone can see a template's remix lineage — and the original creator gets
notified via a Nostr mention when someone forks their work.
## Complexity
**Low–Moderate** — Estimated 2–4 hours
---
## How It Works
### Forking Flow
1. User clicks "Remix this design" on a community template card
2. App imports the template payload into the editor (same as existing "Import" flow)
3. When the user publishes their remixed version, the new event includes a reference
to the original template event ID in its tags
4. Original creator receives a Nostr notification (kind 1 mention) that their template
was remixed
### Nostr Event Tags for a Fork
Add two tags to the published kind 30078 event:
```json
{
"tags": [
["e", "<original-event-id>", "<relay-hint>", "fork"],
["p", "<original-creator-pubkey>"]
]
}
```
- `["e", ..., "fork"]` — marks this as a derivative work; the marker `"fork"` is a
custom convention (Nostr allows arbitrary `e` tag markers)
- `["p", ...]` — notifies the original creator; their client will surface this as a mention
### Attribution Display
In the template detail view, show a "Forked from" row when a `fork` e-tag is present:
```
Forked from: [Alice's Spider-Man Wrap] by [email protected] →
```
Clicking the arrow navigates to the original template in the feed.
---
## Key Components
### 1. Remix Button
Add "Remix this design" to the template card action menu alongside existing Import option.
Visually distinguish it (⑃ fork icon) so users understand it's different from a plain import.
```javascript
function remixTemplate(event) {
importDiscDesignFromPayload(event.content); // existing import function
state.community.forkSourceEventId = event.id;
state.community.forkSourcePubkey = event.pubkey;
showNotification('Template loaded — your remix will credit the original creator.');
}
```
### 2. Fork State
Track pending fork metadata in state so it can be attached at publish time:
```javascript
// Add to state:
fork: {
sourceEventId: null, // event ID of the template being remixed
sourcePubkey: null, // pubkey of the original creator
}
```
Clear `state.fork` when the user starts a fresh project or imports without remixing.
### 3. Publish Hook
In the existing publish function, inject fork tags when `state.fork.sourceEventId` is set:
```javascript
function buildPublishTags(existingTags) {
const tags = [...existingTags];
if (state.fork.sourceEventId) {
tags.push(['e', state.fork.sourceEventId, '', 'fork']);
tags.push(['p', state.fork.sourcePubkey]);
}
return tags;
}
```
### 4. Fork Detection on Render
When rendering a template detail card, check for a `fork` e-tag and fetch + display the
source template title and author:
```javascript
function getForkSource(event) {
const tag = event.tags?.find(t => t[0] === 'e' && t[3] === 'fork');
return tag ? { eventId: tag[1], relayHint: tag[2] } : null;
}
```
### 5. Remix Count
In the community feed, a template's remix count = number of kind 30078 events that
reference it with a `fork` e-tag. This can be fetched lazily:
```javascript
async function fetchRemixCount(eventId) {
const events = await queryRelays(relays, {
kinds: [30078],
'#e': [eventId],
}, { timeoutMs: 2000 });
return events.filter(e => e.tags.some(t => t[0] === 'e' && t[3] === 'fork')).length;
}
```
---
## UI Changes
- Template card action menu: add "⑃ Remix this design" below "Import"
- Editor header: show "Remixing: [original title]" badge while fork state is active
- Editor header: "Clear remix" link to discard attribution and start fresh
- Template detail panel: "Forked from" row (when fork e-tag present)
- Template detail panel: "X remixes" count below reaction row
---
## Performance Considerations
- Fork source lookup is a single relay query — run it lazily when the detail panel opens
- Remix count query can be skipped on initial feed load and fetched on hover/click
- No new relay subscriptions needed — reuses existing `queryRelays()` helper
---
## Limitations
- Fork attribution is opt-in convention — anyone can publish a derivative without tagging;
this feature makes attribution easy but cannot enforce it
- Relay hints in the `e` tag are best-effort; original event may not be on all relays
- No fork graph visualization (full lineage tree) — out of scope for initial version
---
## Future Enhancements
- Fork tree visualization: show the full remix lineage of popular templates
- "See all remixes" panel on template detail
- Notify original creator via kind 1 DM or push notification service
- Attribution in exported PDF metadata
---
## Testing Checklist
- [ ] "Remix this design" imports template content into editor correctly
- [ ] Fork state (`sourceEventId`, `sourcePubkey`) is set after remix
- [ ] Published event contains `["e", ..., "fork"]` and `["p", ...]` tags
- [ ] Fork state clears on new project / plain import
- [ ] "Remixing: [title]" badge shows in editor while fork is active
- [ ] "Clear remix" removes fork state and badge
- [ ] Template detail shows "Forked from" row when fork e-tag is present
- [ ] "Forked from" link navigates to source template in feed
- [ ] Remix count displays on template detail (fetched lazily)
- [ ] Publishing without remix produces no fork tags
+244
View File
@@ -0,0 +1,244 @@
# TMDB Auto-Fill Feature Plan
## Overview
When a user enters an IMDb ID (or searches by title), automatically populate the editor
with high-quality artwork and metadata from The Movie Database (TMDB): front cover poster,
backdrop image, title, year, and genre. This replaces the current manual "paste a URL"
workflow for the most common case — making a case wrap for a specific film.
## Complexity
**Low** — Estimated 2–3 hours
The app already stores `imdbId` in state. TMDB has a free API with IMDb ID lookup.
No new dependencies needed — vanilla `fetch()` is sufficient.
---
## Why TMDB Over OMDB
The app already has an `omdbApiKey` field. OMDB is text-only — it returns metadata but
no image URLs. TMDB provides:
- High-resolution poster images (up to 4K)
- Wide backdrop images (ideal for back cover / full wrap)
- Official logo PNGs with transparent backgrounds (via fanart.tv or TMDB images)
- Cast photos, disc art stills
TMDB API is free for non-commercial use with a v3 API key (takes ~1 minute to register).
---
## API Overview
### Step 1 — Resolve IMDb ID to TMDB ID
```
GET https://api.themoviedb.org/3/find/{imdb_id}?api_key={key}&external_source=imdb_id
```
Response includes `movie_results[0].id` (TMDB ID).
### Step 2 — Fetch Movie Details + Images
```
GET https://api.themoviedb.org/3/movie/{tmdb_id}?api_key={key}&append_to_response=images
```
Key image fields:
```json
{
"poster_path": "/abc123.jpg",
"backdrop_path": "/xyz789.jpg",
"images": {
"posters": [{ "file_path": "...", "width": 2000, "height": 3000 }],
"backdrops": [{ "file_path": "...", "width": 3840, "height": 2160 }],
"logos": [{ "file_path": "...", "width": 1000, "height": 200 }]
}
}
```
### Image URL Construction
```javascript
// TMDB image CDN — choose size: w500, w780, w1280, original
function tmdbImageUrl(path, size = 'original') {
return `https://image.tmdb.org/t/p/${size}${path}`;
}
```
For case wraps, `original` size is best. The CDN serves CORS-friendly URLs.
---
## Key Components
### 1. API Key Storage
```javascript
// Add to state (alongside existing omdbApiKey):
state.tmdbApiKey = localStorage.getItem('casewrap-tmdb-api-key') || '';
```
Add a TMDB API key field to the settings panel next to the existing OMDB key field.
### 2. Core Fetch Functions
```javascript
async function tmdbFindByImdbId(imdbId, apiKey) {
const res = await fetch(
`https://api.themoviedb.org/3/find/${imdbId}?api_key=${apiKey}&external_source=imdb_id`
);
const data = await res.json();
return data.movie_results?.[0] ?? null;
}
async function tmdbFetchImages(tmdbId, apiKey) {
const res = await fetch(
`https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${apiKey}&append_to_response=images&include_image_language=en,null`
);
return await res.json();
}
```
### 3. Auto-Fill Function
Called when the user clicks "Auto-fill from TMDB" or when `imdbId` changes and an API
key is present:
```javascript
async function autoFillFromTMDB() {
const imdbId = state.metadata.imdbId?.trim();
if (!imdbId || !state.tmdbApiKey) return;
showStatus('Fetching artwork from TMDB…');
const match = await tmdbFindByImdbId(imdbId, state.tmdbApiKey);
if (!match) { showStatus('No TMDB match found for this IMDb ID.'); return; }
const details = await tmdbFetchImages(match.id, state.tmdbApiKey);
// Best poster → front cover
const poster = bestImage(details.images.posters, 'portrait');
if (poster) state.slots.front.src = tmdbImageUrl(poster.file_path);
// Best backdrop → back cover
const backdrop = bestImage(details.images.backdrops, 'landscape');
if (backdrop) state.slots.back.src = tmdbImageUrl(backdrop.file_path);
// Metadata
state.metadata.title = details.title ?? state.metadata.title;
renderAll();
showStatus('Artwork loaded from TMDB.');
}
```
### 4. Image Picker (Best Selection Logic)
TMDB returns multiple images — pick the highest-resolution English-language one:
```javascript
function bestImage(images, orientation) {
if (!images?.length) return null;
const aspect = orientation === 'portrait' ? (h, w) => h > w : (h, w) => w > h;
return images
.filter(img => aspect(img.height, img.width))
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0] ?? images[0];
}
```
### 5. Title Search (No IMDb ID)
For users who don't have an IMDb ID handy, add a search-by-title flow:
```
GET https://api.themoviedb.org/3/search/movie?api_key={key}&query={title}&year={year}
```
Show a small result picker (poster thumbnail + title + year) before auto-filling.
```javascript
async function searchTMDB(query, year) {
const params = new URLSearchParams({ api_key: state.tmdbApiKey, query });
if (year) params.set('year', year);
const res = await fetch(`https://api.themoviedb.org/3/search/movie?${params}`);
const data = await res.json();
return data.results?.slice(0, 5) ?? [];
}
```
---
## UI Changes
### Settings Panel
Add TMDB API key field:
```
TMDB API Key: [___________________________] [Get a free key ↗]
```
### Editor — IMDb ID Row
Add auto-fill button next to existing imdbId input:
```
IMDb ID: [tt0145487] [⚡ Auto-fill from TMDB]
```
Or, if no IMDb ID is entered, show a search field:
```
[ Search by title… ] [🔍 Search TMDB]
```
### Auto-Fill Preview Modal
Before applying, show a confirmation panel so users can pick which slots to fill:
```
┌──────────────────────────────────────────┐
│ TMDB: Spider-Man (2002) │
│ │
│ [Poster 2000×3000] [Backdrop 3840×2160]│
│ │
│ Apply to: │
│ ☑ Front cover (poster) │
│ ☑ Back cover (backdrop) │
│ ☐ Disc top (poster) │
│ ☑ Title metadata │
│ │
│ [Cancel] [Apply Selected] │
└──────────────────────────────────────────┘
```
---
## Performance Considerations
- Two sequential TMDB API calls (~200–400ms each) — show spinner, non-blocking
- Image URLs are hotlinked from TMDB CDN — no download or storage needed
- No caching required; TMDB images are stable URLs that don't expire
- `include_image_language=en,null` filter reduces result size significantly
---
## Limitations
- Requires user to register for a free TMDB API key (takes ~1 min at themoviedb.org)
- TMDB covers films and TV well but has sparse data for some older/obscure titles
- Backdrop images are widescreen (16:9); the back cover slot may need manual cropping
- No official disc art — for that, fanart.tv (separate API, free) provides disc art images
- TMDB API is rate-limited (40 requests/10 seconds) — fine for manual use, not bulk
---
## Future Enhancements
- fanart.tv integration: fetch official disc art, transparent logo PNGs, and CD art
- TV series support: fetch season/episode artwork for TV show case wraps
- Auto-fill spine text from TMDB year + runtime
- Bulk fill: apply TMDB artwork to all slots in one click (front, back, disc top, disc bottom)
- Cache TMDB results in `sessionStorage` to avoid redundant API calls when user tweaks the design
---
## Testing Checklist
- [ ] TMDB API key saves to localStorage and restores on reload
- [ ] `tmdbFindByImdbId` returns correct movie for a known IMDb ID (e.g. tt0145487)
- [ ] `tmdbFetchImages` returns poster and backdrop arrays
- [ ] Front cover slot is populated with best portrait image URL
- [ ] Back cover slot is populated with best landscape image URL
- [ ] Title metadata updates from TMDB response
- [ ] Confirmation modal shows correct poster and backdrop thumbnails
- [ ] Unchecked slots in the modal are not overwritten
- [ ] Title search returns results and clicking one auto-fills correctly
- [ ] Missing API key shows prompt to add one rather than a silent failure
- [ ] Invalid IMDb ID shows "no match found" message
- [ ] TMDB image URLs load correctly in the editor canvas
+247
View File
@@ -0,0 +1,247 @@
# Trust & Block Feature Plan
## Overview
Add web-of-trust filtering and user block lists to the community template feed so users can
hide content from bad actors and prioritize templates from trusted sources. Because the app
already uses Nostr for identity and publishing, this feature can be built entirely on existing
Nostr protocol standards (NIPs) with no backend required.
## Complexity
**Moderate–High** — Estimated 4–8 hours to implement (phased; Phase 1 alone is ~2 hours)
---
## Background: Nostr Primitives Available
| NIP | Kind | Purpose |
|-----|------|---------|
| NIP-02 | 3 | Contact / follow list — the source of truth for the social graph |
| NIP-51 | 10000 | Mute list — pubkeys, event IDs, keywords to suppress |
| NIP-56 | 1984 | Content report — flag a pubkey or event as spam/NSFW/illegal |
These events are already signed with the user's key (via NIP-07 extension) and stored on
Nostr relays, so the block/mute list automatically syncs across any Nostr-compatible client
the user signs in to.
---
## Phased Plan
### Phase 1 — Local Block List (no login required)
The simplest layer: any visitor can block a pubkey locally without signing in.
#### Storage
- `localStorage` key: `casewrap-blocked-pubkeys` → JSON array of hex pubkeys
- Survives page refresh; cleared only if the user explicitly unblocks or clears browser data
#### UI
- Add a "Block this author" option to each template card menu (⋮ or right-click)
- Blocked templates are hidden from the feed immediately (no reload needed)
- Settings panel shows blocked pubkey list with per-entry "Unblock" buttons
- Blocked count shown as a subtle badge: "3 authors hidden"
#### Core Functions
```javascript
function blockPubkey(pubkey)
// Add to localStorage block list, re-filter visible templates
function unblockPubkey(pubkey)
// Remove from localStorage block list, re-filter
function isBlocked(pubkey)
// Returns true if pubkey is in the local block list
function applyBlockFilter(templates)
// Filter template array, removing any whose author is blocked
```
---
### Phase 2 — Nostr Mute List Sync (requires NIP-07 login)
Upgrade the local block list to a Nostr-native mute list (NIP-51 kind 10000) so it persists
across devices and syncs with other Nostr apps (Damus, Primal, Snort, etc.).
#### On Login
1. Fetch the user's existing kind 10000 mute list from relays
2. Merge with any locally blocked pubkeys (union, no duplicates)
3. Save merged list back to the relay (signed via `window.nostr.signEvent`)
#### On Block
1. Add pubkey to the in-memory mute list
2. Publish updated kind 10000 event to relays
3. Update `localStorage` as a local cache
#### Event Schema
```json
{
"kind": 10000,
"tags": [
["p", "<hex-pubkey>"],
["p", "<hex-pubkey>"]
],
"content": ""
}
```
#### Core Functions
```javascript
async function fetchMuteList(pubkey)
// Query relays for kind 10000 authored by pubkey, return p-tag array
async function publishMuteList(mutedPubkeys)
// Sign and publish updated kind 10000 event
async function syncMuteListOnLogin()
// Merge relay mute list with localStorage block list on sign-in
```
---
### Phase 3 — Web-of-Trust Feed Filter (requires NIP-07 login)
Only show templates from pubkeys within your social graph — the most effective spam defense.
#### How It Works
1. Fetch the signed-in user's follow list (NIP-02 kind 3) → set of followed pubkeys ("hop 1")
2. Optionally fetch follows-of-follows → "hop 2" (wider but noisier)
3. In the community feed, templates are ranked/filtered by trust tier:
| Tier | Source | Display |
|------|--------|---------|
| Trusted | Pubkey is in your follow list (hop 1) | Full display, shown first |
| Extended | Pubkey is in follows-of-follows (hop 2) | Shown normally |
| Unknown | No social graph overlap | Shown with a "low trust" badge |
| Blocked | In mute list | Hidden entirely |
#### Trust Score (optional enhancement)
For each template author, count how many of your follows also follow them.
This score (0–N) can be shown as a small indicator on the template card.
```javascript
async function fetchFollowList(pubkey)
// Query relays for kind 3, return array of followed pubkeys
function buildTrustSet(myFollows, followsOfFollows)
// Returns { hop1: Set<pubkey>, hop2: Set<pubkey> }
function getTrustTier(authorPubkey, trustSet)
// Returns 'trusted' | 'extended' | 'unknown' | 'blocked'
function sortByTrust(templates, trustSet)
// Put hop-1 templates first, then hop-2, then unknown
```
#### Caching
- Follow lists are large — cache in `sessionStorage` keyed by pubkey
- Re-fetch at most once per session to avoid relay hammering
- Show a loading indicator while the graph is being fetched
#### UI Controls
- Toggle in settings: "Show only trusted authors" (default: off — non-breaking)
- Toggle: "Show low-trust badge on unknown authors" (default: on when logged in)
- Trust tier shown as a subtle icon on template cards (★ trusted, ~ extended, ? unknown)
---
### Phase 4 — Content Reporting (NIP-56)
Allow users to flag templates as spam, NSFW, or abusive so relay operators can act.
#### Report Flow
1. User clicks "Report" in the template card menu
2. Modal asks for report type: `spam` | `nudity` | `illegal` | `impersonation`
3. App publishes a NIP-56 kind 1984 event referencing the template event ID and author pubkey
#### Event Schema
```json
{
"kind": 1984,
"tags": [
["e", "<template-event-id>", "spam"],
["p", "<author-pubkey>"]
],
"content": "Optional description"
}
```
#### Notes
- Reports are published to relays but have no local effect by themselves
- Optionally: auto-hide locally after reporting (user already chose to flag it)
- Reports help relay operators and moderation tools (like Spam Scout) build block lists
```javascript
async function reportTemplate(eventId, authorPubkey, reason, description)
// Sign and publish NIP-56 kind 1984 event
```
---
## Integration Points
All four phases hook into the community feed render loop. The minimal change is:
```javascript
// In the function that renders community template cards:
function renderCommunityFeed(templates) {
const filtered = applyBlockFilter(templates); // Phase 1
const sorted = sortByTrust(filtered, trustSet); // Phase 3
filtered.forEach(t => renderCard(t, getTrustTier(t.author, trustSet)));
}
```
Block/report actions live in the template card's action menu alongside existing
"Copy link" / "Import" options.
---
## Performance Considerations
- Follow list fetches are async and cached — they don't block the initial feed render
- Local block filter runs synchronously on the already-fetched template array (~1ms)
- Follows-of-follows (hop 2) can be 10,000+ pubkeys — use a `Set` for O(1) lookups
- Only fetch hop-2 if the user enables the extended trust option (off by default)
- Relay queries use `REQ` filters with `kinds: [3]` — single-use subscriptions, close after `EOSE`
---
## Limitations
- Web-of-trust only works when the user is signed in via NIP-07
- Users with small follow lists get little benefit from WoT filtering
- Relay queries for follows-of-follows can be slow on large social graphs (~5–15s)
- NIP-56 reports are advisory only — relay operators decide whether to act
- No cross-relay mute list deduplication (if user uses multiple relay sets)
---
## Future Enhancements
- Subscribe to community-maintained block lists (NIP-51 kind 30000 "follow sets" used as shared deny lists)
- Import block list from Nostr clients (Damus, Primal) via relay sync
- NSFW blur overlay instead of full hide (user can click to reveal)
- Relay-level WoT filtering: connect to WoT-curated relays (e.g. relay.nostr.band WoT endpoint) for pre-filtered feeds
- Keyword/hashtag muting (NIP-51 supports `["word", "badterm"]` tags in kind 10000)
---
## Implementation Order (recommended)
1. **Phase 1** — Local block list. Zero dependencies, works for logged-out users, shippable alone.
2. **Phase 3** — WoT feed sort/filter. High value for logged-in users; Phase 2 not required first.
3. **Phase 2** — Nostr mute list sync. Upgrades Phase 1 to be cross-device.
4. **Phase 4** — Reporting. Lowest urgency; depends on relay operator buy-in.
---
## Testing Checklist
- [ ] Blocking a pubkey hides all their templates immediately
- [ ] Unblocking restores their templates
- [ ] Block list persists across page refresh (localStorage)
- [ ] Blocked pubkeys are excluded from all feed views (not just main)
- [ ] Mute list sync fetches existing kind 10000 on login (Phase 2)
- [ ] Publishing updated mute list signs correctly via NIP-07 (Phase 2)
- [ ] Follow list fetch correctly identifies hop-1 pubkeys (Phase 3)
- [ ] Trust tier badges display correctly on template cards (Phase 3)
- [ ] "Show only trusted" toggle hides unknown-tier templates (Phase 3)
- [ ] Report publishes a valid kind 1984 event to relays (Phase 4)
- [ ] No performance regression on feed load (follow list fetched async)
- [ ] Logged-out users see Phase 1 block UI only (no WoT controls)
+365
View File
@@ -0,0 +1,365 @@
# Zaps via Nostr Wallet Connect (NWC) Feature Plan
## Overview
Allow users to zap template creators (and specific templates) directly from the community
feed using Nostr Wallet Connect (NIP-47). The user connects their Lightning wallet once via
a connection string, then taps ⚡ on any template card to send sats to the creator. No
backend required — NWC runs entirely over WebSockets through the user's own wallet relay.
## Complexity
**Moderate–High** — Estimated 6–10 hours
- NWC connection + pay_invoice: ~3 hours
- NIP-57 zap request + LNURL flow: ~3 hours
- UI (zap button, amount modal, settings): ~2 hours
- Crypto dependency setup: ~1 hour
---
## Protocol Overview
Two NIPs work together here:
| NIP | Purpose |
|-----|---------|
| NIP-47 (NWC) | App requests a payment from the user's wallet over an encrypted relay channel |
| NIP-57 | Defines what a "zap" is — a Lightning payment linked to a Nostr event or profile |
**The payment chain:**
```
User clicks ⚡
→ app fetches creator's Lightning address from their cached kind 0 profile (lud16)
→ app queries the creator's LNURL-pay endpoint for a callback URL
→ app builds a NIP-57 zap request (kind 9734) and signs it via NIP-07
→ app POSTs to the LNURL callback → gets back a bolt11 invoice
→ app sends pay_invoice to user's wallet via NWC (NIP-47)
→ wallet pays, publishes kind 9735 zap receipt to relays
```
---
## Key Components
### 1. NWC Wallet Connection (NIP-47)
#### Connection String Format
Wallets (Alby, Mutiny, Zeus, Cashu) generate a URI the user pastes in once:
```
nostr+walletconnect://<walletPubkey>?relay=wss%3A%2F%2Frelay.example.com&secret=<appSecret>
```
| Part | Meaning |
|------|---------|
| `walletPubkey` | Hex pubkey of the wallet service |
| `relay` | WebSocket URL of the relay the wallet listens on |
| `secret` | App-side private key (hex) — used to sign requests and decrypt responses |
#### Storage
- Store connection string in `localStorage` key `casewrap-nwc-uri`
- **Never log or expose the secret** — treat it like a password
- Derive the app pubkey from the secret at runtime for signing
#### Core parse function
```javascript
function parseNWCUri(uri) {
// Returns { walletPubkey, relay, secret } or null if invalid
const url = new URL(uri.replace('nostr+walletconnect://', 'https://'));
return {
walletPubkey: url.hostname,
relay: url.searchParams.get('relay'),
secret: url.searchParams.get('secret'),
};
}
```
---
### 2. NWC pay_invoice Request (NIP-47)
NWC uses encrypted Nostr events to pass commands to the wallet. The app encrypts a JSON
command as kind 23194, publishes it to the wallet relay, and waits for a kind 23195
response from the wallet.
#### Encryption
NWC uses **NIP-04 encryption** (ECDH shared secret → AES-128-CBC).
- Shared secret = ECDH(appSecret, walletPubkey).x
- This is the same cipher used elsewhere in Nostr but keyed with the NWC secret, NOT the
user's NIP-07 key — so `window.nostr.nip04` cannot be used here.
- Requires a small crypto library (see Crypto Dependencies section).
#### Request event (kind 23194)
```json
{
"kind": 23194,
"pubkey": "<appPubkey>",
"tags": [["p", "<walletPubkey>"]],
"content": "<nip04-encrypted JSON>",
"created_at": <unix>,
"id": "<sha256 of canonical form>",
"sig": "<schnorr sig with appSecret>"
}
```
Decrypted content:
```json
{
"method": "pay_invoice",
"params": { "invoice": "lnbc..." }
}
```
#### Response event (kind 23195)
Wallet sends this back, encrypted to appPubkey:
```json
{
"result_type": "pay_invoice",
"result": { "preimage": "<hex>" }
}
// OR on failure:
{
"result_type": "pay_invoice",
"error": { "code": "INSUFFICIENT_BALANCE", "message": "..." }
}
```
#### Core send function
```javascript
async function sendNWCPayment(bolt11) {
// 1. Parse stored NWC URI
// 2. Encrypt pay_invoice request with NIP-04 using appSecret
// 3. Build, sign, and publish kind 23194 to wallet relay
// 4. Open subscription for kind 23195 from walletPubkey, tagged with request event ID
// 5. Wait up to 30s for response, decrypt, return result or throw error
}
```
Note: The app signs kind 23194 events with the NWC `secret` key — NOT the user's NIP-07 key.
All NWC signing is done with the noble-secp256k1 library directly.
---
### 3. NIP-57 Zap Request + LNURL Flow
Before paying, the app must obtain a bolt11 invoice from the creator's Lightning address.
This follows the LNURL-pay + NIP-57 protocol:
#### Step A — Resolve the creator's Lightning address (lud16)
The creator's `lud16` field in their kind 0 profile (e.g. `[email protected]`) maps to a URL:
```javascript
function lud16ToUrl(lud16) {
const [name, domain] = lud16.split('@');
return `https://${domain}/.well-known/lnurlp/${name}`;
}
```
If the creator only has `lud06` (bech32 LNURL), decode it:
```javascript
function decodeLNURL(lud06) {
// bech32-decode lud06 → UTF-8 URL string
}
```
#### Step B — Fetch LNURL-pay metadata
GET the resolved URL. Check `allowsNostr: true` to confirm zap support:
```json
{
"callback": "https://getalby.com/lnurlp/alice/callback",
"minSendable": 1000,
"maxSendable": 100000000000,
"allowsNostr": true,
"nostrPubkey": "<alby-service-pubkey>"
}
```
#### Step C — Build and sign the zap request (kind 9734)
This event is attached to the payment so the recipient sees who zapped them and what for:
```json
{
"kind": 9734,
"pubkey": "<senderPubkey>",
"tags": [
["relays", "wss://relay.damus.io", "wss://nos.lol"],
["amount", "21000"],
["lnurl", "<lnurl>"],
["p", "<recipientPubkey>"],
["e", "<templateEventId>"]
],
"content": "Love this design! ⚡"
}
```
Sign via `window.nostr.signEvent()` (NIP-07) — this DOES use the user's NIP-07 key.
#### Step D — Fetch the invoice
```
GET {callback}?amount=21000&nostr={encodeURIComponent(JSON.stringify(signedZapRequest))}&lnurl={lnurl}
```
Response: `{ "pr": "lnbc21n1p..." }`
#### Step E — Pay via NWC
Pass the `pr` bolt11 string to `sendNWCPayment()`.
```javascript
async function zapCreator(recipientPubkey, eventId, amountSats, message) {
const profile = state.community.profiles[recipientPubkey];
if (!profile?.lud16 && !profile?.lud06) throw new Error('Creator has no Lightning address');
const lnurlInfo = await fetchLNURLPayInfo(profile.lud16 ?? profile.lud06);
if (!lnurlInfo.allowsNostr) throw new Error('Creator wallet does not support zaps');
const zapRequest = await buildAndSignZapRequest(recipientPubkey, eventId, amountSats * 1000, message);
const invoice = await fetchZapInvoice(lnurlInfo, zapRequest, amountSats * 1000);
return await sendNWCPayment(invoice);
}
```
---
### 4. Crypto Dependencies
NWC requires:
- **Schnorr signing** (sign kind 23194 events with appSecret)
- **NIP-04 encryption** (ECDH + AES-128-CBC)
- **Event ID hashing** (SHA-256 of canonical serialization)
The app has no build tools, so load from CDN via ES module imports or `<script>` tags:
```html
<!-- noble-secp256k1: Schnorr signing + ECDH -->
<script type="module">
import * as secp from 'https://esm.sh/@noble/secp256k1@2';
import { sha256 } from 'https://esm.sh/@noble/hashes/sha256';
window._secp = secp;
window._sha256 = sha256;
</script>
```
These two packages together are ~20 KB minified and have no further dependencies.
They are widely used across the Nostr ecosystem.
For bech32 decoding of `lud06` LNURL values, use:
```html
<script src="https://esm.sh/@scure/base@1"></script>
```
---
### 5. UI
#### Settings Panel — Wallet Connection
Add a "Lightning Wallet" section to the existing settings/profile panel:
```
┌─────────────────────────────────────────┐
│ ⚡ Lightning Wallet │
│ │
│ [Paste NWC connection string… ] [Connect] │
│ │
│ Connected: Alby ([email protected]) │
│ Balance: 12,450 sats [Disconnect] │
└─────────────────────────────────────────┘
```
- "Connect" parses and stores the NWC URI; optionally fires a `get_balance` request
- Balance display is optional — costs one extra NWC round-trip; hide if user prefers
- "Disconnect" clears localStorage key and hides zap buttons site-wide
#### Zap Button on Template Cards
Add to the `reactionRow` in both `renderCommunity()` feed cards and `renderCommunityDetail()`:
```
[👍 12] [💬 3] [🔁 5] [⚡ Zap]
```
- Show ⚡ button only when the creator's profile has `lud16` or `lud06`
- If NWC is not connected, clicking ⚡ opens the settings panel to prompt setup
- Show "Zap creator" tooltip on hover
#### Zap Amount Modal
Opens on ⚡ click:
```
┌──────────────────────────────┐
│ ⚡ Zap [email protected] │
│ │
│ [ 21 ] [ 100 ] [ 500 ] │
│ [1000 ] [ Custom… ] │
│ │
│ Message (optional): │
│ [________________________] │
│ │
│ [Cancel] [Send Zap] │
└──────────────────────────────┘
```
- Preset amounts in sats (21 is the canonical "nice" zap amount in Nostr culture)
- Custom amount field with min/max validation against LNURL `minSendable`/`maxSendable`
- Message populates the `content` field of the kind 9734 zap request
- "Send Zap" button shows spinner while in flight, then ✅ success or ❌ error message
---
## State Additions
```javascript
// Add to state object:
nwc: {
uri: '', // raw connection string (from localStorage on load)
connected: false, // true after successful parse + optional balance check
balance: null, // sats, or null if not fetched / not supported
}
```
---
## Performance Considerations
- LNURL fetch and NWC round-trip each take ~1–3 seconds — always show a loading state
- NWC WebSocket opens on-demand per payment and closes immediately after — no persistent connection
- Creator profile `lud16` is already in `state.community.profiles` — no extra relay query needed
- `get_balance` is optional; skip it to save a round-trip on wallet connect
- bech32/secp256k1 libs load once on page init; zap operations after that are <5ms local work
---
## Limitations
- Requires creator to have a Lightning address (`lud16` or `lud06`) in their Nostr profile
- Requires creator's wallet/service to support NIP-57 (`allowsNostr: true`)
- NWC secret gives partial spend authority — user should use a budget-limited NWC URI (Alby and Mutiny both support spending limits on NWC connections)
- `get_balance` and multi-pay are optional NWC methods — not all wallets implement them
- Zap receipts (kind 9735) are published by the recipient's wallet, not the sender — the app can listen for them but cannot guarantee delivery
- No offline support — both LNURL fetch and NWC require live network
---
## Security Notes
- Store the NWC URI in `localStorage` only — never send it to any server
- Never log `uri` or `secret` to console
- The NWC `secret` grants payment authority up to the wallet's configured spending limit — document this clearly in the UI so users understand what they're connecting
- LNURL fetches go to third-party servers (the creator's wallet provider) — no PII is sent beyond the zap request event (which is a public Nostr event anyway)
---
## Future Enhancements
- Zap splits: send a percentage to both template creator and original asset uploader (NIP-57 supports multiple `p` tags)
- Zap leaderboard on template detail: show top zappers (parse kind 9735 receipts)
- Recurring/scheduled zaps for favorite creators
- Show total sats zapped on each template card (sum kind 9735 receipts)
- Cashu/eCash zaps for sub-sat amounts (separate NIP, future consideration)
- WebLN fallback: if user has a WebLN browser extension instead of NWC, use `window.webln.sendPayment()` as a simpler alternative
---
## Testing Checklist
- [ ] NWC URI parses correctly (walletPubkey, relay, secret extracted)
- [ ] Invalid / malformed NWC URI shows clear error in settings
- [ ] `get_balance` request succeeds and displays balance (if wallet supports it)
- [ ] Disconnect clears stored URI and hides zap buttons
- [ ] ⚡ button only shows on cards where creator has lud16/lud06
- [ ] ⚡ click on card without NWC connected opens wallet setup flow
- [ ] LNURL fetch resolves correctly for standard lud16 addresses
- [ ] LNURL fetch resolves correctly for bech32 lud06 addresses
- [ ] `allowsNostr: false` shows friendly "creator wallet doesn't support zaps" message
- [ ] Zap request (kind 9734) is signed via NIP-07 and includes correct tags
- [ ] Invoice is fetched from LNURL callback with correct query params
- [ ] NWC pay_invoice request is encrypted and published to wallet relay
- [ ] Successful payment shows ✅ confirmation in modal
- [ ] NWC error codes (INSUFFICIENT_BALANCE, etc.) show readable messages
- [ ] Amount presets respect LNURL minSendable/maxSendable limits
- [ ] Custom amount validates against min/max before sending
- [ ] NWC connection secret is never logged to console
- [ ] Page reload restores NWC connected state from localStorage
+254
View File
@@ -0,0 +1,254 @@
# Zap-Gated Premium Templates Feature Plan
## Overview
Creators can mark a template as "premium" and set a minimum zap amount. Other users
must send a Lightning zap (via NWC) before the import button unlocks. This creates a
voluntary monetization layer for high-effort designs, with no payment processor, no
platform fee, and no backend — just Nostr zap receipts (kind 9735) checked on the client.
## Complexity
**Moderate** — Estimated 4–6 hours
Depends on ZAPS_NWC_FEATURE.md being implemented first. The zap payment flow reuses
that infrastructure; this feature adds the gating logic on top.
## Prerequisite
**ZAPS_NWC_FEATURE.md must be implemented first.** Zap-gated templates require the
full NWC + NIP-57 zap flow to already work.
---
## How It Works
1. Creator publishes a template tagged as premium with a minimum zap amount
2. In the community feed, premium templates show a ⚡ lock icon instead of Import
3. User clicks the lock → zap modal opens pre-filled with the minimum amount
4. After payment, app listens for a kind 9735 zap receipt on relays
5. Receipt is verified (correct amount, correct template event, correct sender)
6. Import button unlocks for this session; receipt cached in `localStorage`
---
## Nostr Event Tags for Premium Templates
Creator adds two extra tags when publishing a kind 30078 template event:
```json
{
"tags": [
["zap-gate", "true"],
["zap-min", "100"]
]
}
```
| Tag | Value | Meaning |
|-----|-------|---------|
| `zap-gate` | `"true"` | This template requires a zap to unlock |
| `zap-min` | `"100"` | Minimum amount in sats |
These are custom tags — no NIP defines them, but Nostr's open tagging allows this.
Any client that doesn't understand them will simply ignore them.
---
## Key Components
### 1. Premium Tag Detection
```javascript
function isZapGated(event) {
return event.tags?.some(t => t[0] === 'zap-gate' && t[1] === 'true');
}
function getZapMin(event) {
const tag = event.tags?.find(t => t[0] === 'zap-min');
return tag ? parseInt(tag[1], 10) : 21;
}
```
### 2. Local Unlock Cache
After a successful zap, cache the unlock so the user doesn't need to re-pay on every
page load:
```javascript
const UNLOCK_CACHE_KEY = 'casewrap-unlocked-templates';
function markUnlocked(eventId) {
const cache = JSON.parse(localStorage.getItem(UNLOCK_CACHE_KEY) || '{}');
cache[eventId] = { unlockedAt: Date.now() };
localStorage.setItem(UNLOCK_CACHE_KEY, JSON.stringify(cache));
}
function isUnlockedLocally(eventId) {
const cache = JSON.parse(localStorage.getItem(UNLOCK_CACHE_KEY) || '{}');
return !!cache[eventId];
}
```
### 3. Zap Receipt Verification (kind 9735)
After payment, subscribe to kind 9735 events on relays and verify the receipt:
```javascript
async function waitForZapReceipt(templateEventId, senderPubkey, minAmountSats, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Zap receipt timeout')), timeoutMs);
const sub = subscribeRelays(relays, {
kinds: [9735],
'#e': [templateEventId],
}, (receiptEvent) => {
const bolt11Tag = receiptEvent.tags.find(t => t[0] === 'bolt11');
const pTag = receiptEvent.tags.find(t => t[0] === 'P'); // sender pubkey
// Verify sender matches (P tag in 9735 is the zapper's pubkey)
if (pTag?.[1] !== senderPubkey) return;
// Decode amount from bolt11 (or from 'amount' tag if present)
const amountSats = decodeBolt11Amount(bolt11Tag?.[1]) / 1000;
if (amountSats < minAmountSats) return;
clearTimeout(timer);
sub.close();
resolve(receiptEvent);
});
});
}
```
Note: `decodeBolt11Amount` needs to parse the bolt11 invoice to extract the amount.
A minimal bolt11 parser (~2 KB) is available from the `light-bolt11-decoder` package
or can be inlined for just the amount field.
### 4. Full Unlock Flow
```javascript
async function unlockPremiumTemplate(event) {
const minSats = getZapMin(event);
// Show zap modal pre-filled with min amount, locked to minimum
const zapResult = await showZapModal({
recipientPubkey: event.pubkey,
eventId: event.id,
minAmount: minSats,
message: `Unlocking: ${templateTitle(event)}`,
});
if (!zapResult) return; // user cancelled
showStatus('Waiting for zap receipt…');
try {
await waitForZapReceipt(event.id, state.community.myPubkey, minSats);
markUnlocked(event.id);
showStatus('Template unlocked! ✅');
renderCommunityDetail(event); // re-render to show Import button
} catch (err) {
showStatus('Receipt not received — try again or contact the creator.');
}
}
```
### 5. Creator UI — Marking a Template as Premium
Add a "Premium template" toggle to the Publish panel:
```
┌──────────────────────────────────────┐
│ Publish Template │
│ │
│ ☐ Premium template (zap to unlock) │
│ Minimum: [100] sats │
│ │
│ [Publish] │
└──────────────────────────────────────┘
```
When checked, the `zap-gate` and `zap-min` tags are added to the published event.
---
## UI Changes
### Feed Card — Premium Lock Icon
Replace "Import" button with a locked state for gated templates:
```
[⚡ Unlock (100 sats)] ← not yet paid
[Import] ← after unlock
```
Show a small ⭐ or ⚡ badge on the card thumbnail to signal premium status.
### Template Detail Panel
- Show "Premium template — zap to unlock" banner for gated templates
- Show minimum amount prominently: "⚡ 100 sats to unlock"
- After unlock: green "Unlocked ✅" badge replaces the banner
### Own Templates
Creator sees their own premium templates as unlocked by default (no self-payment needed):
```javascript
function isUnlocked(event) {
return !isZapGated(event)
|| isUnlockedLocally(event.id)
|| event.pubkey === state.community.myPubkey;
}
```
---
## Edge Cases
| Scenario | Handling |
|----------|----------|
| Creator's wallet doesn't publish kind 9735 receipt | Timeout after 30s; show "receipt not detected" message; user can retry |
| User zaps less than minimum | Amount check in receipt verification rejects it |
| User clears localStorage | Must re-pay; no cross-device unlock sync |
| Creator updates (replaces) the template event | Old unlock cache entry becomes stale; detect via changed event ID |
---
## Performance Considerations
- `isUnlockedLocally()` is a synchronous localStorage read — runs on every card render
- Kind 9735 subscription is opened only after payment — no background polling
- Receipt subscription times out and closes after 30s regardless of outcome
---
## Limitations
- Unlock is per-device / per-browser (localStorage). No cross-device sync in Phase 1.
- Receipt verification is client-side trust: a determined user could fake a cache entry
in localStorage. This is appropriate for a community platform — not for high-value goods.
- Creators must have a functional Lightning address (lud16) for zaps to work
- If the creator's wallet doesn't publish kind 9735 receipts, the unlock flow times out
(some wallets are inconsistent about this)
---
## Future Enhancements
- Cross-device unlock sync: store verified receipt event ID in user's NIP-51 kind 10001
(pin list) so it syncs across clients
- Time-limited access: `zap-expires` tag unlocks template for N days only
- Subscription model: zap a creator's profile once to unlock all their premium templates
- Revenue stats for creators: count kind 9735 receipts referencing their templates
- Escrow / refund: out of scope (no smart contracts on Lightning without additional infrastructure)
---
## Testing Checklist
- [ ] `isZapGated()` correctly identifies events with `zap-gate: true` tag
- [ ] `getZapMin()` returns correct sat amount from `zap-min` tag
- [ ] Premium feed cards show "⚡ Unlock" button instead of "Import"
- [ ] Zap modal opens pre-filled with minimum amount
- [ ] Amount field rejects values below the minimum
- [ ] After successful zap, app subscribes to kind 9735 on relays
- [ ] Valid receipt with correct sender and amount triggers unlock
- [ ] Receipt with wrong sender is ignored
- [ ] Receipt with insufficient amount is ignored
- [ ] `markUnlocked()` persists to localStorage and survives page reload
- [ ] Unlocked template shows Import button on reload (from cache)
- [ ] Creator's own premium templates are pre-unlocked (no self-payment)
- [ ] 30-second timeout shows helpful error message
- [ ] Publish panel "Premium template" toggle adds correct tags to event
- [ ] Non-premium templates are unaffected by this feature
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+502 -102
View File
File diff suppressed because it is too large. Load diff