mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
fixes and improvements
This commit is contained in:
1 parent
5c56a2d599
commit
d39b672bf5
10 files changed
+2412
-102
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+502
-102
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user