10 KiB
Nostr Entity Decoding & Zaps Implementation Plan
Overview
Two features for post.html and the post-interactions module:
- Nostr Entity Decoding — Detect and render
nostr:prefixed bech32 entities (nprofile, nevent, naddr, note, npub) embedded in post content as clickable cards/links - Zaps — Full NIP-57 zap flow: resolve LNURL from recipient profile, create zap request, fetch bolt11 invoice, pay via Cashu wallet
Key NDK Source References
The NDK codebase already has robust implementations we can leverage:
- Entity regex:
content-tagger.ts—/@|nostr:)(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]+/g - Entity decoding:
entity.ts—getEntity()decodes nip19 and returns NDK objects - LNURL resolution:
ln.ts—getNip57ZapSpecFromLud()handles lud16→endpoint and lud06→endpoint - Zap request creation:
nip57.ts—generateZapRequest()builds and signs kind 9734 - Invoice fetching:
index.ts—getLnInvoice()calls LNURL callback with zap request - Zap invoice parsing:
invoice.ts—zapInvoiceFromEvent()parses kind 9735 with bolt11 decoder - NDK bundle exports: All of these are exported from
ndk-core.bundle.js:getNip57ZapSpecFromLud,generateZapRequest,zapInvoiceFromEvent,NDKZapper,getBolt11Amount
Feature 1: Nostr Entity Decoding
Current State
htmlFormatText()only handles HTTP URLs and image extensionsrenderPostItem()callshtmlFormatText()to render post contentwindow.NostrTools.nip19.decode()is available globally vianostr.bundle.jsand supports all bech32 entity types: npub, note, nprofile, nevent, naddr, nrelay- No current handling of
nostr:prefixed strings in post content
Example Input
Check out nostr:nprofile1qy2hwumn8ghj7un9d3shjtnyd968gmewwp6kyqpqjqhfpx394z4l3zr4zs77zgztsjpy4z6q48q6npxdakv5elyrhaws5cavn4
Entity Types to Handle
| Prefix | NIP-19 Type | Decoded Data | Render As |
|---|---|---|---|
nostr:npub1... |
npub | { type: 'npub', data: hexPubkey } |
Profile link with name/avatar |
nostr:nprofile1... |
nprofile | { type: 'nprofile', data: { pubkey, relays } } |
Profile link with name/avatar |
nostr:note1... |
note | { type: 'note', data: hexEventId } |
Embedded post card |
nostr:nevent1... |
nevent | { type: 'nevent', data: { id, relays, author, kind } } |
Embedded post card |
nostr:naddr1... |
naddr | { type: 'naddr', data: { identifier, pubkey, kind, relays } } |
Link to addressable event |
Implementation Plan
flowchart TD
A[Post content string] --> B[htmlFormatText]
B --> C{Regex: nostr:n...1...}
C -->|Match| D[nip19.decode]
D --> E{Entity type?}
E -->|npub/nprofile| F[Render profile mention link]
E -->|note/nevent| G[Render embedded post placeholder]
E -->|naddr| H[Render addressable event link]
C -->|No match| I[Existing URL/image handling]
F --> J[Async: fetch profile, update DOM]
G --> K[Async: fetch event, render inline card]
Step 1: Enhance htmlFormatText() in utilities.mjs
Add a regex pass for nostr: entities before the URL regex pass:
/nostr:(npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+/gi
For each match:
- Call
window.NostrTools.nip19.decode(entityWithoutNostrPrefix) - Replace with an HTML placeholder element containing a
data-nostr-entityattribute and the bech32 string - Profile mentions (npub/nprofile): render as
<a>link topost.html?npub=...&profile=true&post=false - Event references (note/nevent): render as a
<div class="nostr-embed-placeholder">withdata-event-id - Addressable events (naddr): render as
<a>link
Step 2: Post-render async hydration in post-interactions.mjs
After contentEl.innerHTML = htmlFormatText(...):
- Query all
.nostr-embed-placeholderelements - For profile mentions: fetch profile via
fetchProfile(), update display name and avatar - For event embeds: fetch the referenced event via
fetchEventsFromAllRelays(), render as an inline mini-post card
Step 3: CSS for embedded entities
Add styles for:
.nostr-mention— inline profile mention with avatar thumbnail.nostr-embed— embedded post card with border, content preview, author
Feature 2: Zaps (NIP-57)
Current State
handleZapClick()shows "Coming soon" placeholderextractZapAmount()returns 0 — needs BOLT11 parsingextractZapComment()works correctly- Zap receipt processing (kind 9735) already works in the interaction state tracker
walletPayInvoice()exists ininit-ndk.mjs— pays bolt11 via Cashu walletlight-bolt11-decoderis bundled insidendk-core.bundle.jsbut not directly exported- NDK bundle includes
NDKZapperclass with full NIP-57 flow, but it operates on NDK instances (worker-side) - Profile
lud16field is available from kind 0 metadata
Zap Flow (NIP-57)
sequenceDiagram
participant U as User
participant PI as post-interactions.mjs
participant P as Profile Cache
participant LN as LNURL Server
participant W as Cashu Wallet
participant R as Relays
U->>PI: Click Z button
PI->>PI: Show amount selector popup
U->>PI: Select amount
PI->>P: Get recipient lud16/lud06
P-->>PI: lud16 = user@domain.com
PI->>LN: GET https://domain.com/.well-known/lnurlp/user
LN-->>PI: LNURL-pay metadata with callback, allowsNostr, nostrPubkey
PI->>PI: Create kind 9734 zap request event
PI->>PI: Sign zap request via window.nostr.signEvent
PI->>LN: GET callback?amount=X&nostr=zapRequestJSON&lnurl=...
LN-->>PI: bolt11 invoice
PI->>W: walletPayInvoice with bolt11
W-->>PI: Payment result
PI->>PI: Update Z icon to active state
Note over R: Zap receipt kind 9735 arrives via subscription
R-->>PI: Zap receipt event
PI->>PI: Update zap count display
Implementation Steps
Step 1: LNURL Resolution
Port the pattern from getNip57ZapSpecFromLud() to a main-thread helper:
- Parse
lud16format:user@domain→https://domain/.well-known/lnurlp/user - Also handle
lud06(bech32 encoded LNURL) — NDK usesbech32.decode()from@scure/base - Fetch the LNURL-pay endpoint metadata
- Validate response: check
allowsNostr: true,nostrPubkeyfield - Return
{ callback, minSendable, maxSendable, nostrPubkey, allowsNostr }
Note: The NDK bundle exports getNip57ZapSpecFromLud but it requires an NDK instance. Since our NDK runs in a worker, we'll implement a lightweight version on the main thread that just does the HTTP fetch.
Step 2: Zap Request Creation (kind 9734)
Port the pattern from generateZapRequest():
- Build kind 9734 event with tags:
['relays', ...relayUrls](up to 4 relays)['amount', amountInMillisats.toString()]['lnurl', callback]['e', postId](the event being zapped)['p', postPubkey](the recipient)
- Set
contentto optional comment - Sign via
window.nostr.signEvent()
Step 3: Fetch Bolt11 Invoice
Port the pattern from getLnInvoice():
- Build URL:
callback?amount=X&nostr=JSON.stringify(signedZapRequest) - Fetch and parse response for
{ pr: bolt11Invoice }
Step 4: Payment via Cashu Wallet
Update handleZapClick():
- Show amount selector UI (preset amounts: 21, 100, 500, 1000, 5000 sats)
- Resolve LNURL from recipient profile
- Create and sign zap request
- Fetch bolt11 invoice
- Call
walletPayInvoice(bolt11)from init-ndk.mjs - Handle success/failure states in UI
Step 5: Fix extractZapAmount()
Port the pattern from zapInvoiceFromEvent() which uses light-bolt11-decoder:
- The NDK bundle includes
getSatoshisAmountFromBolt11()which parses the HRP - For our main-thread code, implement a simple HRP parser:
- Parse the prefix after
lnbcto extract amount and multiplier - Multipliers:
m= milli (0.001),u= micro (0.000001),n= nano,p= pico
- Parse the prefix after
Step 6: Zap Amount Selector UI
Create a small popup/modal:
- Preset buttons: ⚡21, ⚡100, ⚡500, ⚡1K, ⚡5K
- Custom amount input field
- Optional comment field
- Send button
- Shows wallet balance from Cashu
- Styled consistently with existing
.divPostItemdesign
Files to Modify
| File | Changes |
|---|---|
www/js/utilities.mjs |
Enhance htmlFormatText() with nostr: entity regex and rendering |
www/js/post-interactions.mjs |
Add async entity hydration after render; implement full zap flow in handleZapClick(); fix extractZapAmount(); add zap amount selector UI |
www/css/client.css |
Add styles for .nostr-mention, .nostr-embed, .zap-selector |
www/post.html |
Import walletPayInvoice from init-ndk.mjs; pass to interactions init |
Dependencies
window.NostrTools.nip19.decode()— already availablewalletPayInvoice()— already available in init-ndk.mjsfetchEventsFromAllRelays()— already availablefetchCachedProfile()/fetchProfile()— already availablewindow.nostr.signEvent()— available via NIP-07 signer- LNURL resolution requires CORS-friendly lightning address servers (standard for most)
Risks & Considerations
- CORS on LNURL servers: Most lightning address providers support CORS, but some may not. Consider a fallback message.
- Cashu wallet balance: User needs sufficient balance. Show balance in zap selector and handle insufficient funds gracefully.
- Recursive embeds: A
nostr:neventcould reference a post that itself containsnostr:entities. Limit embed depth to 1 level. - Performance: Fetching referenced events for embeds should be non-blocking. Show placeholder first, hydrate async.
- BOLT11 parsing: The simple HRP-based parser handles most invoices but edge cases exist. The
light-bolt11-decoderin the NDK bundle is more robust but not directly accessible from the main thread.