Compare commits

...

19 Commits

Author SHA1 Message Date
Laan Tungir
fa0859dcdd Fix post-feed.html: reply composer width — add layout:'inline' to mountComposer
The composer wrapper defaults to width:60% (post-composer.css). Adding
layout:'inline' applies the --inline class which sets width:100%.
2026-06-25 21:29:31 -04:00
Laan Tungir
0c340316ae Fix post-feed.html: reply composer width — add width:100% and box-sizing:border-box to .topPostComposerInput 2026-06-25 21:22:38 -04:00
Laan Tungir
313a55992f Change Nutzap button label from peanut emoji to text 'Nutzap' 2026-06-25 21:18:47 -04:00
Laan Tungir
ece3a136c1 Redesign: Split Zap into two buttons (Zap + Nutzap) with separate counts and popups
- Split single Zap button into Zap ( Lightning) and Nutzap (🥜 Cashu) buttons
- Each button has its own count display and popup dialog
- Zap button: only sends Lightning zaps via Cashu melt, simplified dialog (no rail selector)
- Nutzap button: sends NIP-61 nutzaps, dialog shows recipient's accepted mints
- Removed capability badges (buttons themselves show capability via dimming)
- Removed separate nutzap count display (count now on Nutzap button)
- Added applyZapCapabilityDimming() — dims buttons based on resolveZapCapabilities
- Added promptNutzapDetails() to zaps.mjs — shows recipient mints and shared mint info
- Simplified promptZapDetails() — removed rail selector, Lightning only
- Added .interaction-item.nutzap and .dimmed CSS styles
- All colors use CSS variables only
2026-06-25 21:13:55 -04:00
Laan Tungir
543ae6a1b3 Fix feed.html: disable inline comment rendering that caused flash of replies
Two fixes:
1. post-interactions2.mjs: only call addCommentToPost when getCommentsVisible() is true
2. feed.html: call cards.setCommentsVisible(false) after initPostCards to disable
   inline comment rendering for the new simplified feed

The old feed (feed-old.html) still has the Comments toggle and can re-enable them.
2026-06-25 21:02:21 -04:00
Laan Tungir
508952a1ea Rename feed.html -> feed-old.html, feed2.html -> feed.html
The new simplified feed (formerly feed2.html) is now the primary feed page.
Updated post-feed.html back button to point to feed.html instead of feed2.html.
2026-06-25 20:57:34 -04:00
Laan Tungir
15ef106eb5 Fix feed2.html: add missing walletGetBalance and walletGetMints imports
feed2.html was missing walletGetBalance and walletGetMints in both the
init-ndk.mjs import and the initPostCards call. Without these, the zap
rail selector and capability badges couldn't function — handleZapClick
couldn't resolve zap capabilities or show the rail toggle.
2026-06-25 20:55:02 -04:00
Laan Tungir
a42edcf6d6 Feed upgrade Phases 3-5: zap capability indicators, rail selector, balance checks
Phase 3 — Zap Capability Indicators:
- Added resolveZapCapabilities() to zaps.mjs with 5-min TTL cache
- Checks lud16 (from profile cache) for Lightning capability
- Checks kind 10019 (via walletFetchMintList) for nutzap capability
- Computes shared mints between sender and recipient
- Added capability badges (🥜/) to zap button in post-interactions.mjs
- Badges use var(--accent-color) for available, var(--muted-color) for unavailable
- Added proactive kind 10019 batch fetch for followed users in ndk-worker.js
- New IDB store ndk-nutzap-mintlists for persistent caching (6h TTL)
- Non-blocking: badges appear after async resolution

Phase 4 — Zap Rail Selector:
- Updated promptZapDetails with rail toggle (🥜 Nutzap /  Lightning)
- Shows Cashu balance and insufficient balance warning
- Shows shared mint info when nutzap selected
- Default rail: nutzap for <1000 sats, Lightning for >=1000 sats
- Updated handleZapClick to resolve capabilities and respect user's rail choice
- Added zap rail toggle CSS to client.css (all CSS variables)
- Wired walletGetBalance through post-interactions2.mjs and post-feed.html

Phase 5 — Balance Check and Error Handling:
- Pre-flight balance check before sending (hard stop if insufficient)
- Barely-sufficient balance confirmation dialog
- Friendly error messages for melt/swap/timeout/insufficient failures
- Post-zap balance refresh via ndkWalletBalance event

Review fix: threaded walletGetMints through post-interactions2.mjs and post-feed.html
so resolveZapCapabilities can compute shared mints correctly
2026-06-25 20:48:38 -04:00
Laan Tungir
0267233f87 Fix post-feed.html reply composer formatting — add missing .topPostComposerInput CSS rule
The reply composer was missing the CSS rule that gives it visible formatting
(border, padding, min-height, border-radius). Copied the rule from post.html.
2026-06-25 20:29:51 -04:00
Laan Tungir
6b45092bc8 Fix post-feed.html: recursive reply fetching for nested replies
Replies to replies don't carry the root post ID in their e tags, only their
immediate parent's ID. The old single-level #e filter missed nested replies.

Added fetchRepliesRecursive() which does breadth-first recursive fetching:
- Starts with root post ID, fetches direct replies
- For each reply found, fetches replies to THAT reply
- Continues up to 5 levels deep (REPLY_FETCH_MAX_DEPTH)
- Deduplicates by event ID
- Per-level timeout (8s) to guard against slow relays
- Real-time handler also triggers 1-level recursive fetch for new replies

This matches Amethyst's ThreadFilterAssemblerSubscription approach.
2026-06-25 20:20:10 -04:00
Laan Tungir
6d39188967 Fix post-feed.html: remove collapse, fix missing reply, move composer to top
1. Removed collapse/expand functionality — all replies shown, no hidden counts
2. Fixed missing last reply — added safety check to ensure all fetched replies are rendered
3. Moved reply composer to top (under main post, before replies list)
2026-06-25 20:00:43 -04:00
Laan Tungir
c5f682c5fe Feed upgrade Phase 2.5: Threaded replies with vertical lines in post-feed.html
- Added computeReplyLevels() to compute reply depth from NIP-10 e tags
- Added buildDepthFirstOrder() for depth-first thread sorting (root first, children grouped by parent chronologically)
- Render vertical indent lines using CSS variables (var(--muted-color) for non-selected, var(--accent-color) for focused post)
- Added collapse/expand functionality with hidden reply count
- Added focused post highlight via ?focus=<eventId> URL param
- Real-time subscriptions preserved — new replies appear in correct threaded position
- All colors use CSS variables — no hardcoded colors
2026-06-25 19:53:36 -04:00
Laan Tungir
073fb9e9af Feed upgrade Phase 1 & 2: simplified feed2.html + post-feed.html thread page
Phase 1 (feed2.html):
- Removed inline comment rendering and Comments toggle button
- Comment button now opens post-feed.html?event=<eventId> in new tab
- Removed inline reply composer (kept quote composer)
- Fixed missing queryCache dependency in initPostCards
- Fixed hardcoded color:red -> var(--primary-color)

Phase 2 (post-feed.html):
- New post thread page showing post + all replies + inline composer
- Parses ?event=<hex|nevent|note> URL parameter
- Fetches replies via { kinds: [1], '#e': [postId] }
- Real-time subscriptions for new replies
- Comment button on replies opens sub-threads in new tabs
- Error states for missing/invalid/not-found posts
- All CSS uses variables, no hardcoded colors
2026-06-25 19:45:36 -04:00
Laan Tungir
2f45c78741 Lightweight 17375-only republish: skip 7375 rewrite, no deletions needed
Kind 17375 is a replaceable event (NIP-60, d-tag=''), so publishing a new
one automatically supersedes the old one on relays. The republish now only:
1. ensureDirectNutzapP2pk() - generate privkey if missing
2. Build NIP-60 tag-array payload
3. NIP-44 encrypt + sign + publish single 17375 event
That's 1 signing round-trip instead of 100+.
Reduced timeout from 120s back to 30s.
Bumped WORKER_REVISION to nip60-lightweight-republish-1.
2026-06-25 14:05:33 -04:00
Laan Tungir
6d41680297 Increase walletRepublish timeout to 120s for large wallets
The 45s timeout was too short for wallets with many old 7375 token events —
each deletion requires a signing round-trip through the nos2x extension.
2026-06-25 14:01:01 -04:00
Laan Tungir
b2a50376df Ensure nutzap privkey is always included in kind 17375 republish
- Added ensureDirectNutzapP2pk() call in publishDirectProofs before building wallet payload
- This auto-generates a privkey if one wasn't hydrated from the old 17375 event
- Bumped WORKER_REVISION to nip60-privkey-ensure-1
2026-06-25 13:48:34 -04:00
Laan Tungir
3c4be89ead Add Republish Wallet button to force kind 17375 republish in NIP-60 format
- Added walletRepublish worker handler that calls publishDirectProofs()
- Added walletRepublish() to init-ndk.mjs
- Added republishWallet() to cashu-wallet.mjs controller
- Added 'Republish Wallet (kind 17375)' button to cashu.html sidenav
- Bumped WORKER_REVISION to nip60-republish-1 and cashu-wallet.mjs cache-bust
2026-06-25 13:39:32 -04:00
Laan Tungir
89d2c259f6 Fix event-management Refresh Kind to honor NIP-65 write-only relay designation and not read from write-only relays like sendit.nosflare.com 2026-06-25 13:34:24 -04:00
Laan Tungir
7b77da7349 Fix NIP-60 kind 17375 content format: use tag-array instead of proprietary object
- Changed walletPayloadObj from {mints, nutzap} object to [['mint',url],['privkey',hex]] tag-array
- Removed non-standard pubkey tag from 17375 public tags (belongs on kind 10019)
- Added parseWalletContent() helper that reads both new tag-array and legacy object formats
- Added migrateLegacyWalletEventIfNeeded() for one-time auto-migration of old wallets
- Bumped WORKER_REVISION to nip60-tagarray-fix-1 to force SharedWorker restart
- Added wallet-page.md plan for unified wallet.html (Bitcoin, Cashu, NWC, CLINK)
2026-06-25 12:24:48 -04:00
15 changed files with 4228 additions and 176 deletions

364
plans/feed-upgrade.md Normal file
View File

@@ -0,0 +1,364 @@
# Feed Upgrade — Implementation Plan
## Overview
A series of improvements to the feed page and post interaction bar, centered on:
1. **Simplified feed** — show posts without inline replies, just counts
2. **Post thread page** — click comment button to open post with full comment thread in a new tab
3. **Zap capability indicators** — show what the recipient can receive (nutzap, Lightning)
4. **Zap rail selector** — let users choose between nutzap and Lightning melt
5. **Balance check and error handling** — pre-flight checks and better error messages
The project uses **Cashu as the only payment rail** — nutzaps (NIP-61) for ecash transfers, and Cashu melt for Lightning zaps (NIP-57). No NWC, CLINK, or onchain needed.
### Current state
- [`www/feed.html`](www/feed.html) — shows posts with inline replies (toggled by "Comments" button). Replies appear under the post as they occur. This is unreliable.
- [`www/feed2.html`](www/feed2.html) — copy of feed.html, to be modified per this plan
- [`www/post.html`](www/post.html) — posting/composer page (NOT a thread viewer). Accepts `?event=`, `?npub=`, `?profile=` params.
- [`www/note.html`](www/note.html) — long-form note editor (kind 30023/30024), NOT for kind 1 threads
- [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) — renders the interaction bar (like, comment, quote, zap buttons) and handles zap routing
- [`www/js/zaps.mjs`](www/js/zaps.mjs) — NIP-57 Lightning zap protocol
### URL schema (existing convention)
The project uses `URLSearchParams` with query parameters:
- `?npub=<npub1...>` — Nostr public key (bech32)
- `?pubkey=<hex>` — Nostr public key (hex)
- `?event=<hex|nevent|note>` — Event identifier
- `?auth=required|optional|none` — Auth mode
The new `post-feed.html` page will follow this schema: `post-feed.html?event=<hex|nevent|note>`
### CSS theme
The project uses CSS variables (never hardcode colors):
- `--primary-color` (black in light mode, white in dark mode) — main text/icons
- `--secondary-color` (white in light mode, black in dark mode) — background
- `--accent-color` (#ff0000 red, both modes) — the **only** highlight color
- `--muted-color` (#dddddd light / #777777 dark) — dimmed/disabled
All indicators must use these variables. "Colored" = `var(--accent-color)`, "dimmed" = `var(--muted-color)`.
---
## Phase 1: Simplified Feed (feed2.html)
Remove inline replies from the feed. Show only the original post with interaction counts (likes, comments, zaps, nutzaps). Clicking the comment button opens the post thread page in a **new tab**.
### Tasks
- [x] **1.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts.
- [x] **1.2** Change the comment button behavior — instead of calling `onCommentIntentFn` to show inline comments, open `post-feed.html?event=<eventId>` in a **new tab** using `window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank')`.
- [x] **1.3** Keep the interaction bar functional in the feed:
- ✅ Like button — works inline (no navigation)
- ✅ Zap button — works inline (opens zap dialog, sends nutzap or Lightning melt)
- ✅ Nutzap count display — shows total nutzaps received
- ✅ Comment button — opens `post-feed.html?event=<eventId>` in a new tab
- ✅ Quote button — opens quote composer (no navigation)
- [x] **1.4** Keep real-time updates for counts (likes, zaps, comments, nutzaps) — the feed should still update these counts as events arrive, just not render the reply content inline.
### Files to modify
| File | Changes |
|------|---------|
| [`www/feed2.html`](www/feed2.html) | Remove inline comment rendering, comment button opens post-feed.html in new tab |
---
## Phase 2: Post Thread Page (post-feed.html)
Create a new `www/post-feed.html` page that shows a post with its full comment thread — all replies, with the ability to reply inline.
### URL schema
```
post-feed.html?event=<hex|nevent|note>
```
Accepts the same event identifier formats as the rest of the project:
- Hex event ID: `post-feed.html?event=abc123...`
- nevent: `post-feed.html?event=nevent1q...`
- note: `post-feed.html?event=note1...`
### Tasks
- [x] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav, footer.
- [x] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed.
- [x] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`.
- [x] **2.4** Fetch all replies — kind 1 events with an `e` tag pointing to the post ID. Use `ndkFetchEvents` with a filter like `{ kinds: [1], '#e': [postId] }`. **Updated:** Now uses recursive fetching (`fetchRepliesRecursive`) to find nested replies up to 5 levels deep.
- [x] **2.5** Render the comment thread below the original post:
- Threaded with vertical indent lines (Phase 2.5)
- Each reply rendered using `renderPostItem` from `post-interactions2.mjs`
- Depth-first ordering with level computation from NIP-10 e tags
- [x] **2.6** Add an inline composer at the **top** (under the main post, before replies) for posting replies. The composer:
- Tags the original post: `['e', postId, '', 'reply']`
- Tags the original author: `['p', postPubkey]`
- Uses the existing post composer component (`post-composer.mjs`)
- Styled to match `post.html`'s composer (`.topPostComposerInput` CSS)
- [x] **2.7** Real-time updates — subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive. New replies trigger a 1-level recursive fetch for their sub-replies.
- [x] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions2.mjs`. The comment button on a reply opens `post-feed.html?event=<replyId>` in a new tab for that reply's sub-thread.
### Files to create
| File | Purpose |
|------|---------|
| `www/post-feed.html` | New post thread page — shows post + all replies + inline composer |
---
## Phase 2.5: Threaded Replies with Vertical Lines (post-feed.html)
Upgrade the flat reply list in `post-feed.html` to show **threaded replies with vertical indent lines**, matching Amethyst's thread view.
### How Amethyst does it
Amethyst uses two key components:
1. **`ThreadLevelCalculator.replyLevel(note, cachedLevels)`** — computes the depth of each reply recursively:
- Root post (no `e` tag reply) → level 0
- A reply's level = `max(parent's level for each parent) + 1`
- Parents are determined by NIP-10 `e` tags in the event
2. **`Modifier.drawReplyLevel(level, color, selected)`** — draws vertical lines to the left of each post:
- Draws `level` vertical lines, each 2px wide, spaced 3px apart
- The last line (closest to the post) uses the "selected" color
- All other lines use the "muted" color
- The post content is padded left by `2 + (level * 3)px`
### Visual design
```
Original post (level 0)
│ Reply A (level 1) — direct reply to original
│ │ Reply A1 (level 2) — reply to Reply A
│ │ Reply A2 (level 2) — another reply to Reply A
│ Reply B (level 1) — another direct reply to original
│ │ Reply B1 (level 2) — reply to Reply B
│ │ │ Reply B1a (level 3) — reply to Reply B1
│ Reply C (level 1) — yet another direct reply
```
Each `│` is a vertical line drawn with CSS `border-left` on a container div. The lines use:
- `var(--muted-color)` for non-selected levels
- `var(--accent-color)` for the current post's level (the post being viewed)
### Tasks
- [x] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html`:
- Build a map of `eventId → parentEventId` from the `e` tags (NIP-10 marked reply tags)
- For each event, compute its level by walking up the parent chain to the root
- Root post = level 0, direct reply = level 1, reply to reply = level 2, etc.
- Handle missing parents gracefully (if a parent isn't in the fetched set, treat as level 1)
- Return a `Map<eventId, level>`
- [x] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`):
- Root post first
- Then replies grouped by parent, in chronological order within each group
- This makes the vertical lines visually contiguous — a reply's children appear right below it
- [x] **2.5.3** Render each reply with vertical indent lines:
- Wrap each reply in a container div with `padding-left: calc(level * 12px)`
- For each level 0..N-1, add a vertical line using positioned divs with `background: var(--muted-color)`
- The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)`
- [x] ~~**2.5.4** Add collapse/expand functionality~~**Removed per user request.** All replies are shown, no collapse.
- [x] **2.5.5** Highlight the focused post:
- If the URL has a `focus=<eventId>` param, scroll to and highlight that post
- The highlighted post's vertical line uses `var(--accent-color)` and card gets `var(--accent-color)` outline
### CSS approach (CSS variables only)
```css
.reply-thread-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--muted-color);
}
.reply-thread-line.selected {
background: var(--accent-color);
}
.reply-item {
position: relative;
padding-left: 12px; /* per level */
}
```
### Files to modify
| File | Changes |
|------|---------|
| [`www/post-feed.html`](www/post-feed.html) | Add level computation, depth-first sorting, vertical line rendering, collapse/expand |
---
## Phase 3: Zap Capability Indicators in the Feed
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.
### Design
Compact indicators next to the existing zap button, using the project's CSS variables:
| Indicator | CSS | Meaning |
|-----------|-----|---------|
| 🥜 (accent color) | `color: var(--accent-color)` | Recipient can receive nutzaps — has kind 10019 with a shared mint |
| 🥜 (muted) | `color: var(--muted-color)` | Recipient has kind 10019 but no shared mint with your wallet |
| ⚡ (accent color) | `color: var(--accent-color)` | Recipient can receive Lightning zaps — has lud16 |
| ⚡ (muted) | `color: var(--muted-color)` | Recipient has no lud16 — cannot receive Lightning zaps |
Tooltip on hover shows details: "Can nutzap via mint.minibits.cash/Bitcoin" or "No shared mints — Lightning only" or "No zap capability".
### When and where to fetch the data
Two-tier strategy based on whether the author is a followed user or not:
**Tier 1: Followed users (proactive fetch on startup)**
- The worker already fetches the user's kind 3 (contact list) on startup (line 1799 of `ndk-worker.js`)
- After the kind 3 is loaded, proactively fetch kind 10019 for all followed pubkeys in a batch
- This happens once on startup, results cached in the worker's Dexie/IndexedDB
- Followed users are the most likely zap recipients, and their 10019 rarely changes
- Add a subscription for kind 10019 updates for followed pubkeys (so changes are picked up)
**Tier 2: Non-followed users (lazy fetch on first encounter)**
- When a post by a non-followed user appears in the feed, lazily fetch their kind 10019
- Use the existing `walletFetchMintList` worker function
- Cache the result per pubkey in an in-memory Map with TTL (5 minutes)
- Only fetch once per pubkey per session (unless cache expires)
**What's already available (no fetch needed):**
- lud16 (Lightning address) — already cached in `profile-cache.mjs` when post headers are rendered. The profile fetch already happens for every post in the feed.
- The sender's own wallet mints — already in the worker's `directProofStore`, accessible via `walletGetMints`
### Tasks
- [ ] **3.1** Create a `resolveZapCapabilities(pubkey)` function in [`www/js/zaps.mjs`](www/js/zaps.mjs) that checks:
- Does the recipient have a lud16 (Lightning address)? → check `profile-cache.mjs` cache (already fetched for post rendering) → `canLightning: true`
- Does the recipient have a kind 10019 (nutzap mint list)? → check local cache first, then fetch via `walletFetchMintList` if not cached
- If yes to 10019, which mints? Do any overlap with the sender's wallet mints (from `walletGetMints`)? → `canNutzap: true`, `sharedMints: [...]`
- Return `{ canLightning, canNutzap, sharedMints, nutzapMints, lud16 }`
- [ ] **3.2** Add a proactive kind 10019 batch fetch for followed users in the worker:
- After the kind 3 (contact list) is loaded on startup, collect all followed pubkeys
- Fetch kind 10019 for all followed pubkeys: `ndk.fetchEvents({ kinds: [10019], authors: [...followedPubkeys] })`
- Cache the results in the worker's IndexedDB so they persist across sessions
- Add a subscription for kind 10019 updates for followed pubkeys
- [ ] **3.3** For non-followed users, lazily fetch kind 10019 via `walletFetchMintList`:
- Cache results in an in-memory Map with TTL (5 minutes) in `zaps.mjs`
- Only fetch if not already in the worker's IndexedDB cache or the in-memory cache
- Non-blocking: render the interaction bar without badges, then update when the fetch completes
- [ ] **3.4** Update the zap button rendering in `post-interactions.mjs` to show capability indicators:
- Add a small badge element next to the zap button
- Show 🥜 in `var(--accent-color)` if `canNutzap` is true
- Show 🥜 in `var(--muted-color)` if recipient has 10019 but no shared mint
- Show ⚡ in `var(--accent-color)` if `canLightning` is true
- Add tooltip with shared mint details
- [ ] **3.5** Make the capability check async and non-blocking — render the interaction bar immediately, then update the badges when the capability check completes. The lud16 check is instant (from profile cache); the 10019 check may take a moment for non-followed users.
### Files to modify
| File | Changes |
|------|---------|
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()` with caching, lazy fetch for non-followed users |
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add capability badges to zap button rendering |
| [`www/ndk-worker.js`](www/ndk-worker.js) | Add proactive kind 10019 batch fetch for followed users on startup |
---
## Phase 4: Zap Rail Selector in the Zap Dialog
When the user taps the zap button, show which rails are available and let them choose.
```mermaid
flowchart TD
A[User taps zap button] --> B[Show zap dialog with amount input]
B --> C{What rails are available?}
C -->|Nutzap + Lightning| D[Show rail toggle: 🥜 Nutzap | ⚡ Lightning]
C -->|Lightning only| E[Show Lightning only]
C -->|Nutzap only| F[Show Nutzap only]
C -->|Neither| G[Show error: recipient cannot receive zaps]
D --> H[User selects rail + amount]
H --> I{Rail selected}
I -->|Nutzap| J[Send NIP-61 nutzap via walletSendNutzap]
I -->|Lightning| K[Create LN invoice, melt Cashu via walletPayInvoice]
E --> K
F --> J
```
### Tasks
- [ ] **4.1** Update `promptZapDetails` in [`www/js/zaps.mjs`](www/js/zaps.mjs) to accept and display rail options:
- If nutzap is available → show "🥜 Nutzap" option
- If Lightning is available → show "⚡ Lightning" option
- Let the user pick which rail to use via a toggle/selector
- Selected rail uses `var(--accent-color)`, unselected uses `var(--muted-color)`
- [ ] **4.2** Show balance info in the dialog: "Cashu balance: 261 sats". If amount > balance → show warning in `var(--accent-color)`.
- [ ] **4.3** Default rail selection:
- If nutzap is available and amount < 1000 sats default to nutzap (lower fees)
- If nutzap is available and amount 1000 sats default to Lightning
- If nutzap is not available default to Lightning
- [ ] **4.4** Pass the selected rail back to `handleZapClick` in `post-interactions.mjs` so it uses the right send function.
- [ ] **4.5** Show shared mint info when nutzap is selected: "Will send via mint.minibits.cash/Bitcoin"
### Files to modify
| File | Changes |
|------|---------|
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Update `promptZapDetails` with rail selector UI |
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Update `handleZapClick` to respect user's rail choice |
---
## Phase 5: Balance Check and Error Handling
- [ ] **5.1** Before sending a zap, check the user's Cashu balance (via `walletGetBalance`):
- If balance < zap amount show "Insufficient balance. Current: X sats, needed: Y sats" in `var(--accent-color)`
- If balance is sufficient but barely show confirmation "This will use most of your balance. Continue?"
- [ ] **5.2** Improve error messages for melt failures:
- "Mint couldn't route Lightning payment" (mint liquidity issue)
- "Mint returned an error: [details]" (mint API error)
- "Proofs were spent but payment failed try again or contact the mint"
- [ ] **5.3** After a successful zap, refresh the balance display.
### Files to modify
| File | Changes |
|------|---------|
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add balance check before zap, improve error handling |
---
## Implementation Order
1. **Phase 1** (simplified feed) remove inline replies from feed2.html, comment button opens post-feed.html in new tab
2. **Phase 2** (post thread page) create post-feed.html to show full post + comment thread
3. **Phase 3** (zap capability indicators) show what the recipient can receive
4. **Phase 4** (rail selector) let users choose nutzap vs Lightning melt
5. **Phase 5** (balance + errors) pre-flight checks and better error messages
Phases 1-2 are the feed restructuring. Phases 3-5 are zap improvements.

178
plans/wallet-page.md Normal file
View File

@@ -0,0 +1,178 @@
# Cashu Wallet Enhancements — Implementation Plan
## Overview
Enhance the existing `www/cashu.html` page and zap flow, centered on **Cashu as the primary (and only) payment rail**. The core functionality is already implemented — this plan adds UI improvements to show zap capabilities in the feed and let users choose their zap rail.
### Design philosophy: Cashu-only
The project uses **Cashu for everything** — no NWC, CLINK, onchain, or Lightning channels to maintain:
| Payment type | How it works | Already implemented? |
|-------------|-------------|---------------------|
| **Nutzap (NIP-61)** | Send ecash proofs locked to recipient's p2pk via kind 9321 | ✅ Yes — `walletSendNutzap` |
| **Lightning zap (NIP-57)** | Melt Cashu ecash at a mint to pay a BOLT11 invoice | ✅ Yes — `walletPayInvoice` |
| **P2P ecash transfer** | Send cashuB token directly to another user | ✅ Yes — `walletSendToken` |
| **Lightning deposit** | Pay a BOLT11 invoice, mint issues ecash proofs | ✅ Yes — `walletCreateDeposit` |
| **Lightning withdrawal** | Melt ecash to pay an external BOLT11 invoice | ✅ Yes — `walletPayInvoice` |
---
## Phase 1: NIP-60 Compliance Fix (DONE ✅)
Already completed and deployed (v0.7.19v0.7.24):
- Fixed kind 17375 content format from proprietary `{mints, nutzap}` object to NIP-60 tag-array `[["mint",url],["privkey",hex]]`
- Removed non-standard `pubkey` tag from 17375 public tags
- Added `parseWalletContent()` backwards-compat shim for reading both formats
- Added auto-migration for legacy 17375 events on startup
- Added "Republish Wallet (kind 17375)" button with lightweight 17375-only republish
- Added `ensureDirectNutzapP2pk()` call in `publishDirectProofs` so privkey is always included
---
## Phase 2: Zap Capability Indicators in the Feed
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.
### Current state
The interaction bar in [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) already renders:
- ⚡ Zap button with count
- 🥜 Nutzap count display (separate element showing total nutzaps received)
### What to add
Small capability icons/badges next to the zap button showing what the recipient can receive:
```
┌──────────────────────────────────────────────────┐
│ ⚡ 5 🥜 2 🥜✅ ⚡✅ 💬 3 🔄 1 2h ago │
│ └─ zap ─┘ └─ nutzap ─┘ └─ capability badges ─┘ │
└──────────────────────────────────────────────────┘
```
Or a more compact design — color the zap icon differently based on capabilities:
| Visual | Meaning |
|--------|---------|
| ⚡ (default color) | Recipient can receive Lightning zaps (has lud16) |
| ⚡ + 🥜 (nut badge) | Recipient can also receive nutzaps (has kind 10019 with shared mint) |
| ⚡ (dimmed) | Recipient cannot receive any zaps (no lud16, no 10019) |
### Implementation
- [ ] **2.1** Create a `resolveZapCapabilities(pubkey)` function that checks:
- Does the recipient have a lud16 (Lightning address)? → can receive Lightning zaps
- Does the recipient have a kind 10019 (nutzap mint list)? → can receive nutzaps
- If yes to 10019, which mints? Do any overlap with the sender's wallet mints?
- [ ] **2.2** Cache the results per pubkey (the kind 10019 and lud16 don't change often). Use a simple in-memory Map with TTL.
- [ ] **2.3** Update `createInteractionItem` for the zap type to show capability indicators:
- Add a small 🥜 badge on the zap button if the recipient can receive nutzaps
- Add a tooltip showing the shared mints
- Dim the zap button if the recipient can't receive any zaps
- [ ] **2.4** Fetch kind 10019 events for post authors as the feed loads. The worker already fetches kind 10019 for nutzap sending — extend this to cache the results for feed display. Use the existing `walletFetchMintList` worker function.
- [ ] **2.5** Show shared mint info on hover/tooltip: "Can nutzap via mint.minibits.cash/Bitcoin" or "No shared mints — Lightning only" or "No zap capability"
### Where the code lives
| File | What to change |
|------|---------------|
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add capability badges to zap button rendering |
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()` function (or extend existing `resolveZapSpecForPubkey`) |
| [`www/ndk-worker.js`](www/ndk-worker.js) | May need a lightweight "fetch 10019 for display" message (or reuse `walletFetchMintList`) |
---
## Phase 3: Zap Rail Selector in the Zap Dialog
When the user taps the zap button, show which rails are available and let them choose.
```mermaid
flowchart TD
A[User taps zap button] --> B[Show zap dialog with amount input]
B --> C{What rails are available?}
C -->|Nutzap + Lightning| D[Show rail toggle: 🥜 Nutzap | ⚡ Lightning]
C -->|Lightning only| E[Show Lightning only]
C -->|Nutzap only| F[Show Nutzap only]
C -->|Neither| G[Show error: recipient cannot receive zaps]
D --> H[User selects rail + amount]
H --> I{Rail selected}
I -->|Nutzap| J[Send NIP-61 nutzap via walletSendNutzap]
I -->|Lightning| K[Create LN invoice, melt Cashu via walletPayInvoice]
E --> K
F --> J
```
- [ ] **3.1** Update `promptZapDetails` in `zaps.mjs` to accept and display rail options:
- If nutzap is available (recipient has 10019 with shared mint) → show "🥜 Nutzap" option
- If Lightning is available (recipient has lud16) → show "⚡ Lightning" option
- Let the user pick which rail to use via a toggle/selector
- [ ] **3.2** Show balance info in the dialog:
- "Cashu balance: 261 sats"
- If amount > balance → show warning "Insufficient balance"
- [ ] **3.3** Default rail selection:
- If nutzap is available and amount < 1000 sats default to nutzap (lower fees)
- If nutzap is available and amount 1000 sats default to Lightning (more reliable for larger amounts)
- If nutzap is not available default to Lightning
- [ ] **3.4** Pass the selected rail back to `handleZapClick` in `post-interactions.mjs` so it uses the right send function.
---
## Phase 4: cashu.html UI Polish
Minor UI improvements to the existing cashu.html page:
- [ ] **4.1** Consider renaming the page title from "CASHU" to "WALLET" since it's the project's unified wallet.
- [ ] **4.2** Update the sidenav entry in `index.html` from "CASHU" to "WALLET" (keeping the same `cashu.html` URL).
- [ ] **4.3** Add a "Zap via Lightning" info note in the Withdraw panel explaining that this melts ecash to pay a Lightning invoice same mechanism used for Lightning zaps.
---
## NIP Compliance Reference
| Rail | NIP | Kind(s) | Status |
|------|-----|---------|--------|
| Cashu Wallet | NIP-60 | 17375, 7375, 7376, 375 | Fixed and working |
| Cashu Nutzap | NIP-61 | 10019, 9321 | Working |
| Lightning Zap | NIP-57 | 9734, 9735 | Working (via Cashu melt) |
---
## Key Files
### Files to modify
| File | Changes |
|------|---------|
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add zap capability badges to interaction bar, pass selected rail to zap handler |
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()`, update `promptZapDetails` with rail selector |
| [`www/cashu.html`](www/cashu.html) | Minor UI polish (title, info notes) |
| [`www/index.html`](www/index.html) | Sidenav label: "CASHU" "WALLET" |
### Files reused as-is
| File | Why no changes needed |
|------|----------------------|
| [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) | Controller already wraps all worker functions |
| [`www/ndk-worker.js`](www/ndk-worker.js) | All wallet functions already exist (payInvoice, sendNutzap, fetchMintList) |
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | All wallet exports already exist |
---
## Implementation Order
1. **Phase 1** (DONE) NIP-60 compliance fix
2. **Phase 2** (zap capability indicators) show what the recipient can receive in the feed
3. **Phase 3** (rail selector) let users choose nutzap vs Lightning melt in the zap dialog
4. **Phase 4** (UI polish) minor label/info changes
No new files needed. No new infrastructure. Just UI improvements on top of the existing working zap flow.

View File

@@ -637,6 +637,7 @@
<button id="btnCheckProofs" class="btn cashuBtn" type="button" style="margin-bottom: 10px;">Check Proofs with Mints</button>
<div id="divCheckProofsResult">No proof check run yet.</div>
<button id="btnRefreshWallet" class="btn cashuBtn" type="button">Refresh Wallet State</button>
<button id="btnRepublishWallet" class="btn cashuBtn" type="button" style="margin-top: 5px;">Republish Wallet (kind 17375)</button>
</div>
<div id="divZapsSection" class="sidenavSection">
@@ -728,7 +729,7 @@
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=mint-discovery-3';
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=nip60-republish-1';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
@@ -2348,6 +2349,23 @@
}
});
const btnRepublishWallet = document.getElementById('btnRepublishWallet');
btnRepublishWallet?.addEventListener('click', async () => {
try {
if (!walletController?.hasWallet?.()) {
setStatus('No wallet to republish', true);
return;
}
setStatus('Republishing wallet event (kind 17375)...');
await walletController.republishWallet();
await refreshAllWalletViews();
setStatus('Wallet republished successfully');
} catch (error) {
console.error('[cashu.html] Republish wallet failed:', error);
setStatus(error.message || 'Republish failed', true);
}
});
btnSaveZapsSettings?.addEventListener('click', async () => {
try {
const amount = Number(inpZapDefaultAmount?.value || 0);

View File

@@ -1388,6 +1388,136 @@ a.nostr-embed-preview-text:hover {
color: var(--accent-color);
}
/* Zap rail selector (Phase 4 — nutzap vs Lightning) */
.zap-rail-toggle {
display: flex;
gap: 6px;
}
.zap-rail-option {
flex: 1;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--background-color);
color: var(--muted-color);
font-family: var(--font-family);
font-size: 13px;
padding: 8px 10px;
cursor: pointer;
text-align: center;
}
.zap-rail-option.active {
border-color: var(--accent-color);
color: var(--accent-color);
}
.zap-rail-single {
display: block;
border: 1px solid var(--accent-color);
border-radius: 8px;
background: var(--background-color);
color: var(--accent-color);
font-family: var(--font-family);
font-size: 13px;
padding: 8px 10px;
text-align: center;
}
.zap-selector-balance {
font-size: 12px;
color: var(--muted-color);
}
.zap-selector-warning {
font-size: 12px;
color: var(--accent-color);
display: none;
}
.zap-selector-mint-info {
font-size: 12px;
color: var(--muted-color);
}
.zap-selector-error {
font-size: 13px;
color: var(--accent-color);
}
.zap-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Nutzap recipient mint list (promptNutzapDetails) */
.zap-selector-mint-list {
list-style: none;
margin: 4px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.zap-selector-mint-list-item {
font-size: 11px;
color: var(--muted-color);
word-break: break-all;
}
.zap-selector-mint-list-item.shared {
color: var(--accent-color);
}
/* Nutzap interaction item (post footer) */
.interaction-item.nutzap {
flex: 0 0 auto;
}
.interaction-item.nutzap .interaction-icon {
font-size: 14px;
line-height: 1;
}
.interaction-item.nutzap.zap-pending {
color: var(--muted-color);
}
.interaction-item.nutzap.zap-pending .interaction-icon,
.interaction-item.nutzap.zap-pending .interaction-icon.active,
.interaction-item.nutzap.zap-pending .interaction-count {
color: var(--muted-color) !important;
transition: color 240ms linear !important;
}
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-icon,
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-icon.active,
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-count {
color: var(--accent-color) !important;
}
.interaction-item.nutzap.zap-success,
.interaction-item.nutzap.zap-success .interaction-icon,
.interaction-item.nutzap.zap-success .interaction-icon.active,
.interaction-item.nutzap.zap-success .interaction-count {
color: var(--accent-color) !important;
}
/* Dimmed (capability unavailable) interaction items */
.interaction-item.dimmed {
opacity: 0.45;
cursor: not-allowed;
}
.interaction-item.dimmed:hover {
color: var(--muted-color);
}
.interaction-item.dimmed:hover .interaction-icon {
color: var(--muted-color);
}
/* ************************************************************************* */
/* AUTHOR HEADER */
/* ************************************************************************* */

View File

@@ -693,6 +693,7 @@
const eventRelayPresence = new Map(); // eventKey -> Map(relayUrl, boolean)
const eventRelayPublishState = new Map(); // eventKey -> Map(relayUrl, 'idle'|'checking'|'publishing'|'failed')
let subscribedRelayUrls = [];
let readableRelayUrls = []; // read/both relays only (excludes write-only) for Refresh Kind queries
let refreshKindDebugInFlight = false;
const autoRefreshedKinds = new Set();
const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -1027,12 +1028,26 @@
? rows.filter((r) => r?.fromRelayList === true)
: rows;
// subscribedRelayUrls includes all relays (read, write, both) so that
// write-only relays still appear as publish-target chips and are valid
// targets for the "publish to missing relays" flow.
const urls = [...new Set(scopedRows
.map((r) => String(r?.url || '').trim())
.filter(Boolean))];
subscribedRelayUrls = urls;
// readableRelayUrls excludes write-only relays (NIP-65 type === 'write')
// so the Refresh Kind query loop never tries to READ from a write-only
// relay. Falls back to the full list when type info is unavailable.
const hasTypeinfo = scopedRows.some((r) => r?.type);
readableRelayUrls = hasTypeinfo
? [...new Set(scopedRows
.filter((r) => r?.type !== 'write')
.map((r) => String(r?.url || '').trim())
.filter(Boolean))]
: [...urls];
for (const relayMap of eventRelayPresence.values()) {
for (const url of subscribedRelayUrls) {
if (!relayMap.has(url)) relayMap.set(url, false);
@@ -1815,8 +1830,13 @@
});
setSubscribedRelayUrlsFromRelayData(relayData);
const relayUrls = [...subscribedRelayUrls];
console.log('[event-management] refresh:relayUrls scoped for query', relayUrls);
// Use readableRelayUrls (excludes write-only relays) so Refresh Kind
// never tries to READ from a write-only relay like sendit.nosflare.com.
const relayUrls = [...readableRelayUrls];
console.log('[event-management] refresh:relayUrls scoped for query (read-only)', relayUrls, {
subscribedRelayUrls: subscribedRelayUrls,
readableRelayUrls: readableRelayUrls
});
if (!relayUrls.length) {
divKindsStatus.textContent = `Kind ${targetKind}: no relays found in kind 10002 relay list`;

729
www/feed-old.html Normal file
View File

@@ -0,0 +1,729 @@
<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FEED</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/post-composer.css" />
<link rel="stylesheet" href="./css/dot-menu.css" />
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) document.body.classList.add('dark-mode');
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: flex-start !important;
align-content: flex-start !important;
gap: 10px;
overflow-y: auto;
overflow-anchor: none;
}
#divHint,
#divFeed,
#btnSeeMore {
width: 80%;
min-width: 300px;
max-width: 700px;
}
#divHint {
text-align: center;
color: var(--muted-color);
font-size: 80%;
margin-top: 5px;
}
#divFeed {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
#btnSeeMore {
display: none;
margin-bottom: 20px;
}
.divPostItem .post-composer-wrapper {
width: 100%;
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">FEED</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divHint">Loading follows…</div>
<div id="divFeed"></div>
<button id="btnSeeMore" class="btn">See More</button>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
</div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<div id="divFiles"></div>
<div id="divFeedSettings" class="sidenavSection">
<div class="sidenavSectionTitle">Feed</div>
<label class="sidenavRowToggle" for="chkAutoplayVideos">
<span>Autoplay videos</span>
<input id="chkAutoplayVideos" type="checkbox" />
</label>
</div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">loading...</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
subscribe,
disconnect,
getVersion,
queryCache,
ndkFetchEvents,
fetchCachedProfile,
storeProfile,
publishEvent,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
getRelayData,
getUserSettings,
patchUserSettings,
onUserSettings,
ensureMuteListLoaded,
isEventMuted,
addMute
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { initPostCards } from './js/post-interactions2.mjs';
import { mountComposer } from './js/post-composer.mjs';
const INITIAL_POSTS_LOAD = 10;
const SEE_MORE_INCREMENT = 20;
const MIN_BOOTSTRAP_POSTS = 10;
const FEED_BOOTSTRAP_WINDOWS_SECONDS = [3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600];
const FEED_BOOTSTRAP_WINDOW_LIMIT = 120;
let currentPubkey = null;
let updateIntervalId = null;
let unsubscribeUserSettings = null;
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let isNavOpen = false;
const posts = [];
const postIds = new Set();
const renderedPostIds = new Set();
let displayCount = INITIAL_POSTS_LOAD;
let followedPubkeys = [];
const feedPubkeys = new Set();
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divFooterRight = document.getElementById('divFooterRight');
const divFeed = document.getElementById('divFeed');
const divHint = document.getElementById('divHint');
const btnSeeMore = document.getElementById('btnSeeMore');
const chkAutoplayVideos = document.getElementById('chkAutoplayVideos');
let autoplayVideosEnabled = false;
let liveFeedSubscriptionKey = '';
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
let isBootstrappingFeed = false;
let hasBootstrappedFeed = false;
const bufferedFeedEvents = [];
function toNostrNoteRef(postId) {
try {
return window?.NostrTools?.nip19?.noteEncode
? window.NostrTools.nip19.noteEncode(postId)
: postId;
} catch (_) {
return postId;
}
}
function focusInlineComposer(hostEl, content = '') {
if (!hostEl) return false;
hostEl.innerText = content || '';
hostEl.focus();
document.dispatchEvent(new Event('selectionchange'));
hostEl.dispatchEvent(new Event('input', { bubbles: true }));
window.requestAnimationFrame(() => {
hostEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
return true;
}
function getOrCreateInlineComposer(postId, postEl) {
if (!postId || !postEl) return null;
const existing = inlineComposerInstances.get(postId);
if (existing?.hostEl?.isConnected && existing?.instance) {
return existing;
}
const hostEl = document.createElement('div');
hostEl.className = 'inlinePostComposerInput';
hostEl.dataset.inlineComposerFor = postId;
postEl.appendChild(hostEl);
const entry = {
hostEl,
tags: [],
instance: null
};
const instance = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: true,
hideOnEscape: true,
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return;
await publishEvent({
kind: 1,
content: text,
tags: entry.tags,
created_at: Math.floor(Date.now() / 1000)
});
}
});
entry.instance = instance;
inlineComposerInstances.set(postId, entry);
return entry;
}
function handleComposerCommentIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'reply'],
['p', postPubkey]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, '');
}
function handleComposerQuoteIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const noteRef = toNostrNoteRef(postId);
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'mention'],
['p', postPubkey],
['q', postId]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, `nostr:${noteRef}`);
}
async function handleMuteIntent({ eventData }) {
const targetPubkey = String(eventData?.pubkey || '').trim();
if (!targetPubkey || targetPubkey === currentPubkey) return;
const ok = window.confirm(`Mute ${targetPubkey.slice(0, 8)}${targetPubkey.slice(-4)}?`);
if (!ok) return;
await addMute('p', targetPubkey, false);
for (let i = posts.length - 1; i >= 0; i -= 1) {
if (posts[i]?.pubkey === targetPubkey) {
postIds.delete(posts[i]?.id);
posts.splice(i, 1);
}
}
renderFeed();
}
const cards = initPostCards({
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent: handleComposerCommentIntent,
onQuoteIntent: handleComposerQuoteIntent,
onMuteIntent: handleMuteIntent
});
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
isNavOpen ? closeNav() : openNav();
}
function isOriginalPost(event) {
const eTags = event.tags?.filter(t => t[0] === 'e') || [];
if (eTags.length === 0) return true;
return eTags.every(t => t[3] === 'mention');
}
function applyVideoAutoplaySetting(enabled) {
autoplayVideosEnabled = !!enabled;
if (chkAutoplayVideos) chkAutoplayVideos.checked = autoplayVideosEnabled;
const videoEls = Array.from(divFeed.querySelectorAll('video'));
videoEls.forEach((videoEl) => {
videoEl.controls = true;
videoEl.playsInline = true;
videoEl.setAttribute('playsinline', '');
videoEl.preload = 'metadata';
if (autoplayVideosEnabled) {
videoEl.autoplay = true;
videoEl.muted = true;
videoEl.setAttribute('autoplay', '');
videoEl.setAttribute('muted', '');
const playPromise = videoEl.play?.();
if (playPromise?.catch) playPromise.catch(() => {});
} else {
videoEl.autoplay = false;
videoEl.muted = false;
videoEl.removeAttribute('autoplay');
videoEl.removeAttribute('muted');
}
});
}
function applySettings(settings) {
const nextAutoplayEnabled = !!settings?.feed?.videoAutoplay;
applyVideoAutoplaySetting(nextAutoplayEnabled);
}
async function persistAutoplaySetting(enabled) {
try {
await patchUserSettings({
feed: {
videoAutoplay: !!enabled
}
});
} catch (error) {
console.warn('[feed.html] Failed to persist feed autoplay setting:', error?.message || error);
}
}
function upsertFeedPost(evt) {
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
if (isEventMuted(evt)) return false;
postIds.add(evt.id);
posts.push(evt);
return true;
}
function renderFeed() {
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const toShow = posts.slice(0, displayCount);
divFeed.innerHTML = '';
renderedPostIds.clear();
toShow.forEach((post) => {
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
divFeed.appendChild(postEl);
renderedPostIds.add(post.id);
});
cards.wireInteractions(toShow.map(p => p.id), { currentPubkey });
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = followedPubkeys.length > 0
? `${posts.length} posts from ${followedPubkeys.length} follows`
: 'Follow users to populate your feed.';
divFooterRight.textContent = `${posts.length} posts`;
}
async function fetchFeedWindow(authors, { since, limit = FEED_BOOTSTRAP_WINDOW_LIMIT, renderAfterCache = false } = {}) {
if (!Array.isArray(authors) || authors.length === 0) return;
const filters = { kinds: [1], authors, limit };
if (typeof since === 'number' && Number.isFinite(since) && since > 0) {
filters.since = since;
}
const cached = await queryCache(filters).catch(() => []);
cached.forEach((evt1) => {
upsertFeedPost(evt1);
});
if (renderAfterCache && posts.length > 0) {
renderFeed();
}
void ndkFetchEvents(filters).then((fresh) => {
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
upsertFeedPost(evt1);
});
if (posts.length > 0) {
renderFeed();
}
}).catch(() => {});
}
async function bootstrapFeedPosts(authors) {
if (!Array.isArray(authors) || authors.length === 0) {
hasBootstrappedFeed = true;
renderFeed();
return;
}
isBootstrappingFeed = true;
const now = Math.floor(Date.now() / 1000);
await Promise.allSettled(
FEED_BOOTSTRAP_WINDOWS_SECONDS.map((windowSeconds) =>
fetchFeedWindow(authors, {
since: now - windowSeconds,
limit: FEED_BOOTSTRAP_WINDOW_LIMIT,
renderAfterCache: true
})
)
);
if (posts.length < MIN_BOOTSTRAP_POSTS) {
await fetchFeedWindow(authors, {
since: 0,
limit: FEED_BOOTSTRAP_WINDOW_LIMIT * 2,
renderAfterCache: true
});
}
bufferedFeedEvents.forEach((evt1) => {
if (feedPubkeys.has(evt1.pubkey)) {
upsertFeedPost(evt1);
}
});
bufferedFeedEvents.length = 0;
isBootstrappingFeed = false;
hasBootstrappedFeed = true;
renderFeed();
}
function ensureLiveFeedSubscription() {
const authors = Array.from(feedPubkeys).filter(Boolean);
if (authors.length === 0) return;
const key = authors.slice().sort().join(',');
if (key === liveFeedSubscriptionKey) return;
const newestKnown = posts.reduce((max, p) => Math.max(max, p?.created_at || 0), 0);
const now = Math.floor(Date.now() / 1000);
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
subscribe(
{ kinds: [1], authors, since },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
liveFeedSubscriptionKey = key;
console.log('[feed.html] Live feed subscription active', { authors: authors.length, since });
}
async function ingestContactList(evt) {
if (!evt || evt.kind !== 3 || evt.pubkey !== currentPubkey) return;
const pTags = evt.tags?.filter(tag => tag[0] === 'p' && tag[1]) || [];
followedPubkeys = pTags.map(tag => tag[1]);
const newAuthors = followedPubkeys.filter((pk) => !feedPubkeys.has(pk));
newAuthors.forEach((pk) => feedPubkeys.add(pk));
ensureLiveFeedSubscription();
const allAuthors = Array.from(feedPubkeys);
if (!hasBootstrappedFeed) {
await bootstrapFeedPosts(allAuthors);
return;
}
if (newAuthors.length > 0) {
try {
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
renderFeed();
} catch (_) {
renderFeed();
}
} else {
renderFeed();
}
}
async function logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
cards.destroy();
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
if (unsubscribeUserSettings) {
unsubscribeUserSettings();
unsubscribeUserSettings = null;
}
localStorage.clear();
sessionStorage.clear();
location.reload(true);
}
async function updateFooter() {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
} catch (_) {}
}
(async function main() {
try {
const versionInfo = await getVersion();
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
});
chkAutoplayVideos?.addEventListener('change', async () => {
const nextEnabled = !!chkAutoplayVideos.checked;
applyVideoAutoplaySetting(nextEnabled);
await persistAutoplaySetting(nextEnabled);
});
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
await ensureMuteListLoaded();
try {
const settings = await getUserSettings();
applySettings(settings || {});
} catch (error) {
console.warn('[feed.html] Failed to load user settings:', error?.message || error);
applySettings({});
}
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
applySettings(settings || {});
});
}
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await updateFooter();
updateIntervalId = setInterval(updateFooter, 1000);
window.addEventListener('ndkRelayActivity', (e) => {
setRelayActivityState(e.detail.relayUrl, e.detail.activity);
});
window.addEventListener('ndkEvent', async (e) => {
const evt = e.detail;
if (!evt) return;
if (evt.kind === 3 && evt.pubkey === currentPubkey) {
await ingestContactList(evt);
return;
}
if (evt.kind === 1 && feedPubkeys.has(evt.pubkey) && isOriginalPost(evt)) {
if (isBootstrappingFeed) {
bufferedFeedEvents.push(evt);
return;
}
if (!upsertFeedPost(evt)) return;
renderFeed();
}
});
subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
const cachedContactEvents = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []);
if (cachedContactEvents.length > 0) {
const latest = cachedContactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
await ingestContactList(latest);
}
if (feedPubkeys.size === 0) {
divHint.textContent = 'Follow users to populate your feed.';
}
} catch (error) {
console.error('[feed.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
}
})();
</script>
</body>
</html>

View File

@@ -87,9 +87,7 @@
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
</div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
@@ -157,6 +155,8 @@
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetBalance,
walletGetMints,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -281,18 +281,10 @@ import { initPostCards } from './js/post-interactions2.mjs';
return entry;
}
function handleComposerCommentIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'reply'],
['p', postPubkey]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, '');
function handleComposerCommentIntent({ postId }) {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
}
function handleComposerQuoteIntent({ postId, postPubkey }) {
@@ -335,11 +327,14 @@ import { initPostCards } from './js/post-interactions2.mjs';
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetBalance,
walletGetMints,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -348,6 +343,12 @@ import { initPostCards } from './js/post-interactions2.mjs';
onMuteIntent: handleMuteIntent
});
// Disable inline comment rendering — the new feed shows posts only.
// Comments are viewed on the post-feed.html thread page (new tab).
if (typeof cards.setCommentsVisible === 'function') {
cards.setCommentsVisible(false);
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
@@ -639,12 +640,6 @@ import { initPostCards } from './js/post-interactions2.mjs';
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
@@ -720,7 +715,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
} catch (error) {
console.error('[feed.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
}
})();
</script>

View File

@@ -15,6 +15,7 @@ import {
walletRemoveMint,
walletGetTransactions,
walletShutdown,
walletRepublish,
walletPublishMintList,
walletFetchMintList,
onWalletStatus,
@@ -397,6 +398,16 @@ export async function createCashuWalletController({ pubkey, relayUrls = [] } = {
}
}
async function republishWallet() {
await ensureWallet();
const result = await walletRepublish();
if (Array.isArray(result?.mints)) {
mintsCache = uniqueMints(result.mints);
}
applyBalancePayload(result || {});
return { mints: [...mintsCache] };
}
cleanupFns.push(onWalletStatus((payload) => {
if (typeof payload?.hasWallet === 'boolean') {
hasWalletCache = payload.hasWallet;
@@ -431,6 +442,7 @@ export async function createCashuWalletController({ pubkey, relayUrls = [] } = {
waitForDeposit,
getTransactionHistory,
publishMintList,
republishWallet,
fetchRecipientMintList,
onWalletEvent,
subscribeTransactions,

View File

@@ -184,7 +184,7 @@ export async function initNDKPage(options = {}) {
// All pages in www/ directory, so worker path is always the same.
// Include explicit worker revision token so updated SharedWorker code is guaranteed to restart.
const WORKER_REVISION = 'relay-events-db-6';
const WORKER_REVISION = 'nip60-lightweight-republish-1';
const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`;
// Initialize NDK SharedWorker (shared across all tabs/pages)
@@ -1425,6 +1425,10 @@ export function walletShutdown() {
return sendWorkerRequest('walletShutdown', {}, 10000, 'walletShutdown timeout');
}
export function walletRepublish() {
return sendWorkerRequest('walletRepublish', {}, 30000, 'walletRepublish timeout');
}
export function walletPublishMintList(relays = [], receiveMints = []) {
return sendWorkerRequest('walletPublishMintList', { relays, receiveMints }, 25000, 'walletPublishMintList timeout');
}

View File

@@ -9,8 +9,10 @@ import { createProfileCache } from './profile-cache.mjs';
import { mountDotMenu } from './dot-menu.mjs';
import {
promptZapDetails,
promptNutzapDetails,
prepareZapInvoiceForEvent,
resolveNutzapSpecForPubkey,
resolveZapCapabilities,
extractZapAmount as extractZapAmountFromReceipt,
extractZapComment as extractZapCommentFromReceipt
} from './zaps.mjs';
@@ -879,12 +881,13 @@ const ICON_LABELS = {
like: { label: 'Like', activeLabel: 'Like' },
comment: { label: 'Comment', activeLabel: 'Comment' },
quote: { label: 'Quote', activeLabel: 'Quote' },
zap: { label: 'Zap', activeLabel: 'Zap' }
zap: { label: 'Zap', activeLabel: 'Zap' },
nutzap: { label: 'Nutzap', activeLabel: 'Nutzap' }
};
/**
* Generate HTML for interaction label text
* @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap'
* @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap', 'nutzap'
* @param {boolean} active - Whether the label is in active/selected state
* @returns {string} HTML string with label text
*/
@@ -957,6 +960,8 @@ let publishEventFn = null;
let walletPayInvoiceFn = null;
let walletSendNutzapFn = null;
let walletFetchMintListFn = null;
let walletGetMintsFn = null;
let walletGetBalanceFn = null;
let getRelayDataFn = null;
let getUserSettingsFn = null;
let patchUserSettingsFn = null;
@@ -1049,7 +1054,7 @@ export function renderFooterRow(eventId, eventData, options = {}) {
onClick: () => handleQuoteClick(eventId, eventData.pubkey, currentPubkey, quoteItem)
});
// Zap button
// Zap button (Lightning only)
const zapItem = createInteractionItem({
type: 'zap',
count: state.zapTotal,
@@ -1059,8 +1064,16 @@ export function renderFooterRow(eventId, eventData, options = {}) {
});
updateZapCountDisplay(zapItem, state);
const nutzapCountEl = createNutzapCountDisplay(state);
// Nutzap button (Cashu ecash)
const nutzapItem = createInteractionItem({
type: 'nutzap',
count: state.nutzapTotal,
active: false,
title: 'Nutzap',
onClick: () => handleNutzapClick(eventId, eventData.pubkey, currentPubkey, nutzapItem)
});
updateNutzapButtonCount(nutzapItem, state);
const midControls = document.createElement('div');
midControls.className = 'divPostMidControls';
@@ -1069,7 +1082,7 @@ export function renderFooterRow(eventId, eventData, options = {}) {
midControls.appendChild(quoteItem);
container.appendChild(zapItem);
container.appendChild(nutzapCountEl);
container.appendChild(nutzapItem);
container.appendChild(midControls);
// Time as the final item in the same row
@@ -1079,7 +1092,11 @@ export function renderFooterRow(eventId, eventData, options = {}) {
container.appendChild(timeEl);
footerRow.appendChild(container);
// Async, non-blocking capability dimming.
// Resolved after the bar is in the DOM so feed rendering is never blocked.
setTimeout(() => applyZapCapabilityDimming(eventId, eventData.pubkey, zapItem, nutzapItem), 0);
return footerRow;
}
@@ -1126,7 +1143,7 @@ export function renderInteractionBar(postId, postData, options = {}) {
onClick: () => handleQuoteClick(postId, postData.pubkey, currentPubkey, quoteItem)
});
// Zap button
// Zap button (Lightning only)
const zapItem = createInteractionItem({
type: 'zap',
count: state.zapTotal,
@@ -1137,14 +1154,27 @@ export function renderInteractionBar(postId, postData, options = {}) {
});
updateZapCountDisplay(zapItem, state);
const nutzapCountEl = createNutzapCountDisplay(state);
// Nutzap button (Cashu ecash)
const nutzapItem = createInteractionItem({
type: 'nutzap',
count: state.nutzapTotal,
active: false,
showCount: state.nutzapTotal > 0,
title: 'Nutzap',
onClick: () => handleNutzapClick(postId, postData.pubkey, currentPubkey, nutzapItem)
});
updateNutzapButtonCount(nutzapItem, state);
container.appendChild(zapItem);
container.appendChild(nutzapCountEl);
container.appendChild(nutzapItem);
container.appendChild(likeItem);
container.appendChild(commentItem);
container.appendChild(quoteItem);
// Async, non-blocking capability dimming.
// Resolved after the bar is in the DOM so feed rendering is never blocked.
setTimeout(() => applyZapCapabilityDimming(postId, postData.pubkey, zapItem, nutzapItem), 0);
return container;
}
@@ -1194,10 +1224,10 @@ export function updateInteractionBar(postId, state) {
updateZapCountDisplay(zapItem, state);
}
// Update nutzap count display
const nutzapCountEl = bar.querySelector('.nutzap-count-display');
if (nutzapCountEl) {
updateNutzapCountDisplay(nutzapCountEl, state);
// Update nutzap button count
const nutzapItem = bar.querySelector('.interaction-item.nutzap');
if (nutzapItem) {
updateNutzapButtonCount(nutzapItem, state);
}
}
@@ -1272,34 +1302,80 @@ function updateZapCountDisplay(itemEl, state = {}) {
countEl.textContent = formatCount(zapTotal);
}
function createNutzapCountDisplay(state = {}) {
const el = document.createElement('div');
el.className = 'nutzap-count-display';
el.title = 'Nutzaps';
const emojiEl = document.createElement('span');
emojiEl.className = 'nutzap-emoji';
emojiEl.textContent = '🥜';
const countEl = document.createElement('span');
countEl.className = 'nutzap-count-value';
el.appendChild(emojiEl);
el.appendChild(countEl);
updateNutzapCountDisplay(el, state);
return el;
}
function updateNutzapCountDisplay(el, state = {}) {
const countEl = el?.querySelector?.('.nutzap-count-value');
/**
* Update the count display on the Nutzap interaction button.
* Mirrors updateZapCountDisplay but for the nutzap button.
* @param {HTMLElement} itemEl - The nutzap interaction item element
* @param {Object} state - The interaction state
*/
function updateNutzapButtonCount(itemEl, state = {}) {
const countEl = itemEl?.querySelector?.('.interaction-count');
if (!countEl) return;
const nutzapTotal = Math.max(0, Number(state?.nutzapTotal || 0));
const hasCount = nutzapTotal > 0;
countEl.textContent = formatCount(nutzapTotal);
el.classList.toggle('has-count', hasCount);
}
// =============================================================================
// ZAP CAPABILITY DIMMING
// =============================================================================
//
// Instead of separate capability badges, the Zap and Nutzap buttons themselves
// indicate capability via dimming:
// - Zap button dimmed when recipient has no lud16 Lightning address
// - Nutzap button dimmed when recipient has no shared mints for nutzaps
//
// Dimming is applied asynchronously after the bar is in the DOM so feed
// rendering is never blocked. Colors come exclusively from CSS variables.
/**
* Dim the Zap and/or Nutzap buttons based on the recipient's zap capabilities.
* Resolves capabilities async and applies a `.dimmed` class + tooltip to
* buttons whose rail is unavailable. Non-throwing.
*
* @param {string} postId - the event id (used for logging only)
* @param {string} pubkey - the post author's pubkey
* @param {HTMLElement} zapItem - the Zap (Lightning) interaction item
* @param {HTMLElement} nutzapItem - the Nutzap interaction item
*/
function applyZapCapabilityDimming(postId, pubkey, zapItem, nutzapItem) {
if (!pubkey) return;
if (typeof walletFetchMintListFn !== 'function' && typeof fetchProfile !== 'function') {
return; // nothing to resolve with
}
if (!zapItem && !nutzapItem) return;
resolveZapCapabilities(pubkey, {
fetchProfile,
fetchMintList: walletFetchMintListFn || undefined,
getSenderMints: walletGetMintsFn || undefined,
logPrefix: '[post-interactions][zap-caps]'
}).then((caps) => {
// Elements may have been detached from the DOM by the time this resolves.
if (zapItem && zapItem.isConnected) {
if (!caps.canLightning) {
zapItem.classList.add('dimmed');
zapItem.title = 'No Lightning address';
} else {
zapItem.classList.remove('dimmed');
zapItem.title = caps.lud16 ? `Lightning: ${caps.lud16}` : 'Zap';
}
}
if (nutzapItem && nutzapItem.isConnected) {
if (!caps.canNutzap) {
nutzapItem.classList.add('dimmed');
nutzapItem.title = caps.hasMintList
? 'No shared mints for nutzap'
: 'No shared mints for nutzap';
} else {
nutzapItem.classList.remove('dimmed');
nutzapItem.title = 'Nutzap';
}
}
}).catch((error) => {
console.warn('[post-interactions][zap-caps] dimming failed', error?.message || error);
});
}
// =============================================================================
@@ -1323,6 +1399,66 @@ function isZapTimeoutError(error) {
return msg.includes('timeout');
}
/**
* Classify a zap/melt payment error into a user-friendly message.
* Hides raw mint API errors while still being informative.
* @param {Error|*} error
* @returns {string}
*/
function friendlyZapErrorMessage(error) {
const raw = String(error?.message || error || '').trim();
const msg = raw.toLowerCase();
if (msg.includes('melt') || msg.includes('swap') || msg.includes('proof')) {
return 'Mint couldn\'t route Lightning payment. The mint may not have enough Lightning liquidity.';
}
if (msg.includes('timeout')) {
return 'Payment timed out. The mint may be slow or unresponsive.';
}
if (msg.includes('insufficient') || msg.includes('balance')) {
return 'Insufficient Cashu balance for this payment.';
}
return raw || 'Zap failed.';
}
/**
* Fetch the current user's Cashu balance in sats.
* Returns null when the wallet function is unavailable or the fetch fails.
* @returns {Promise<number|null>}
*/
async function fetchWalletBalanceSats() {
if (typeof walletGetBalanceFn !== 'function') return null;
try {
const result = await walletGetBalanceFn();
const balance = Number(result?.balance ?? result?.sats ?? result);
if (Number.isFinite(balance) && balance >= 0) {
return Math.floor(balance);
}
} catch (error) {
console.warn('[post-interactions][zap] fetchWalletBalanceSats:failed', error?.message || error);
}
return null;
}
/**
* Refresh the wallet balance display after a zap.
* Re-fetches the balance and dispatches an `ndkWalletBalance` event so any
* footer/dialog balance displays can update. Non-throwing.
* @returns {Promise<void>}
*/
async function refreshWalletBalanceDisplay() {
const balanceSats = await fetchWalletBalanceSats();
if (balanceSats === null) return;
try {
window.dispatchEvent(new CustomEvent('ndkWalletBalance', {
detail: { balanceSats, source: 'post-interactions-zap' }
}));
} catch (error) {
console.warn('[post-interactions][zap] refreshWalletBalanceDisplay:dispatch-failed', error?.message || error);
}
}
// =============================================================================
// INTERACTION HANDLERS
// =============================================================================
@@ -1558,6 +1694,18 @@ async function saveZapDefaultsToSettings({ amountSats, comment }) {
});
}
/**
* Handle Zap (Lightning) button click.
*
* Sends a Lightning zap via Cashu melt: create a LN invoice from the
* recipient's lud16, then pay it via walletPayInvoiceFn. No rail selector —
* nutzaps have their own button and handler.
*
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The interaction item element
*/
async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
if (!postId || !postPubkey || !itemEl) return;
if (itemEl.dataset.busy === '1') return;
@@ -1583,34 +1731,31 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
let paymentSucceeded = false;
try {
if (typeof walletPayInvoiceFn !== 'function' && typeof walletSendNutzapFn !== 'function') {
throw new Error('Cashu wallet payer is not configured on this page');
if (typeof walletPayInvoiceFn !== 'function') {
throw new Error('Lightning zap unavailable (walletPayInvoice not configured)');
}
let nutzapSpec = null;
if (typeof walletFetchMintListFn === 'function') {
// --- Fetch the user's Cashu balance for the dialog ----------------------
let balanceSats = null;
if (typeof walletGetBalanceFn === 'function') {
try {
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
fetchMintList: walletFetchMintListFn,
logPrefix: '[post-interactions][nutzap]'
});
} catch (_nutzapDiscoveryError) {
nutzapSpec = null;
const balanceResult = await walletGetBalanceFn();
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
if (Number.isFinite(balance) && balance >= 0) {
balanceSats = Math.floor(balance);
}
} catch (balanceError) {
console.warn('[post-interactions][zap] handleZapClick:balance-failed', balanceError?.message || balanceError);
}
}
console.log('[post-interactions][zap] handleZapClick:nutzap-support', {
recipient: String(postPubkey || '').slice(0, 8) + '…',
hasNutzapSupport: Boolean(nutzapSpec?.mints?.length),
mintCount: Array.isArray(nutzapSpec?.mints) ? nutzapSpec.mints.length : 0
});
const zapDefaults = await getZapDefaultsFromSettings();
const details = await promptZapDetails({
defaultAmountSats: zapDefaults.amountSats,
defaultComment: zapDefaults.comment,
defaultShouldZap: true,
onSaveDefault: saveZapDefaultsToSettings
onSaveDefault: saveZapDefaultsToSettings,
balanceSats
});
if (!details) {
console.log('[post-interactions][zap] handleZapClick:cancelled-by-user');
@@ -1652,40 +1797,59 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
return;
}
if (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function') {
if (countEl) countEl.textContent = 'send…';
console.log('[post-interactions][zap] handleZapClick:sending-nutzap');
const nutzapResult = await walletSendNutzapFn({
amount: details.amountSats,
memo: details.comment,
targetPubkey: postPubkey,
eventId: postId,
recipientMints: nutzapSpec.mints,
recipientP2pk: nutzapSpec.p2pk,
recipientRelays: nutzapSpec.relays
});
console.log('[post-interactions][zap] handleZapClick:nutzap-ok', nutzapResult || {});
} else {
if (typeof walletPayInvoiceFn !== 'function') {
throw new Error('LN zap fallback unavailable (walletPayInvoice missing)');
// --- Pre-flight balance check (Phase 5) --------------------------------
// A final safety net right before sending, in case the user ignored the
// dialog warning or the balance changed between dialog and send.
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
const preflightBalance = await fetchWalletBalanceSats();
if (preflightBalance !== null) {
if (preflightBalance < zapAmount) {
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
console.warn('[post-interactions][zap] handleZapClick:insufficient-balance', {
balance: preflightBalance, needed: zapAmount
});
if (countEl) {
countEl.textContent = 'low';
countEl.title = msg;
setTimeout(() => {
updateZapCountDisplay(itemEl, getPostState(postId));
}, 2200);
}
window.alert(msg);
return;
}
const { invoice } = await prepareZapInvoiceForEvent({
eventId: postId,
recipientPubkey: postPubkey,
amountSats: details.amountSats,
comment: details.comment,
fetchProfile,
getRelayData: getRelayDataFn,
logPrefix: '[post-interactions][zap]'
});
if (countEl) countEl.textContent = 'pay…';
console.log('[post-interactions][zap] handleZapClick:paying-invoice');
const paymentResult = await walletPayInvoiceFn(invoice);
console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {});
// "Barely sufficient" warning: remaining balance would be less than
// 10% of the current balance.
const remaining = preflightBalance - zapAmount;
const tenPercent = Math.floor(preflightBalance * 0.10);
if (remaining > 0 && remaining < tenPercent) {
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
const proceed = window.confirm(confirmMsg);
if (!proceed) {
console.log('[post-interactions][zap] handleZapClick:declined-low-balance');
return;
}
}
}
// --- Create the LN invoice and pay it via Cashu melt -------------------
const { invoice } = await prepareZapInvoiceForEvent({
eventId: postId,
recipientPubkey: postPubkey,
amountSats: details.amountSats,
comment: details.comment,
fetchProfile,
getRelayData: getRelayDataFn,
logPrefix: '[post-interactions][zap]'
});
if (countEl) countEl.textContent = 'pay…';
console.log('[post-interactions][zap] handleZapClick:paying-invoice');
const paymentResult = await walletPayInvoiceFn(invoice);
console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {});
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
itemEl.classList.add('zap-success');
@@ -1702,15 +1866,28 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
itemEl.classList.remove('zap-success');
updateZapCountDisplay(itemEl, state);
}, 1800);
// --- Post-zap balance refresh (Phase 5) --------------------------------
// Refresh the wallet balance display so the user sees their updated
// balance after a successful zap. Non-blocking — failures are logged
// but never surface to the user since the zap itself succeeded.
refreshWalletBalanceDisplay().catch((refreshError) => {
console.warn('[post-interactions][zap] handleZapClick:balance-refresh-failed', refreshError?.message || refreshError);
});
} catch (error) {
const timeoutFailure = isZapTimeoutError(error);
const friendlyMessage = friendlyZapErrorMessage(error);
console.error('[post-interactions][zap] handleZapClick:failed', error);
if (countEl) {
countEl.textContent = timeoutFailure ? 'check' : 'fail';
countEl.title = friendlyMessage;
setTimeout(() => {
updateZapCountDisplay(itemEl, getPostState(postId));
}, timeoutFailure ? 2800 : 2200);
}
// Surface a user-friendly error message. Avoids exposing raw mint API
// errors while still being informative.
window.alert(friendlyMessage);
itemEl.classList.remove('zap-success');
} finally {
clearInterval(zapPulseInterval);
@@ -1728,6 +1905,255 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
}
}
/**
* Handle Nutzap (Cashu ecash) button click.
*
* Checks the recipient's kind 10019 for shared mints, shows a nutzap-specific
* dialog (with the recipient's accepted mints), and sends ecash via
* walletSendNutzapFn. Mirrors handleZapClick's UI feedback (pulse animation,
* count update, balance check).
*
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The nutzap interaction item element
*/
async function handleNutzapClick(postId, postPubkey, currentPubkey, itemEl) {
if (!postId || !postPubkey || !itemEl) return;
if (itemEl.dataset.busy === '1') return;
const iconContainer = itemEl.querySelector('.interaction-icon-container');
const countEl = itemEl.querySelector('.interaction-count');
console.log('[post-interactions][nutzap] handleNutzapClick:start', {
postId: String(postId || '').slice(0, 8) + '…',
recipient: String(postPubkey || '').slice(0, 8) + '…'
});
itemEl.dataset.busy = '1';
itemEl.classList.add('zap-pending');
updateIconState(iconContainer, 'nutzap', true);
let pendingAccent = false;
const zapPulseInterval = setInterval(() => {
pendingAccent = !pendingAccent;
itemEl.classList.toggle('zap-pending-accent', pendingAccent);
}, 500);
let paymentSucceeded = false;
try {
if (typeof walletSendNutzapFn !== 'function') {
throw new Error('Nutzap unavailable (walletSendNutzap not configured)');
}
// --- Resolve zap capabilities to confirm nutzap is possible -------------
let zapCaps = null;
try {
zapCaps = await resolveZapCapabilities(postPubkey, {
fetchProfile,
fetchMintList: walletFetchMintListFn || undefined,
getSenderMints: walletGetMintsFn || undefined,
logPrefix: '[post-interactions][nutzap-caps]'
});
} catch (capsError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:caps-failed', capsError?.message || capsError);
}
const canNutzap = Boolean(zapCaps?.canNutzap);
const sharedMints = Array.isArray(zapCaps?.sharedMints) ? zapCaps.sharedMints : [];
const recipientMints = Array.isArray(zapCaps?.nutzapMints) ? zapCaps.nutzapMints : [];
console.log('[post-interactions][nutzap] handleNutzapClick:caps', {
recipient: String(postPubkey || '').slice(0, 8) + '…',
canNutzap,
sharedMints: sharedMints.length,
recipientMints: recipientMints.length
});
if (!canNutzap || sharedMints.length === 0) {
window.alert('Recipient cannot receive nutzaps (no shared mints)');
console.log('[post-interactions][nutzap] handleNutzapClick:no-shared-mints');
return;
}
// --- Resolve the nutzap spec (mints, p2pk, relays) for the send --------
let nutzapSpec = null;
try {
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
fetchMintList: walletFetchMintListFn,
logPrefix: '[post-interactions][nutzap]'
});
} catch (nutzapDiscoveryError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:spec-failed', nutzapDiscoveryError?.message || nutzapDiscoveryError);
window.alert('Recipient cannot receive nutzaps (no shared mints)');
return;
}
if (!nutzapSpec?.mints?.length) {
window.alert('Recipient cannot receive nutzaps (no shared mints)');
return;
}
// --- Fetch the user's Cashu balance for the dialog ----------------------
let balanceSats = null;
if (typeof walletGetBalanceFn === 'function') {
try {
const balanceResult = await walletGetBalanceFn();
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
if (Number.isFinite(balance) && balance >= 0) {
balanceSats = Math.floor(balance);
}
} catch (balanceError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:balance-failed', balanceError?.message || balanceError);
}
}
const zapDefaults = await getZapDefaultsFromSettings();
const details = await promptNutzapDetails({
defaultAmountSats: zapDefaults.amountSats,
defaultComment: zapDefaults.comment,
defaultShouldZap: true,
onSaveDefault: saveZapDefaultsToSettings,
balanceSats,
recipientMints: nutzapSpec.mints,
sharedMints
});
if (!details) {
console.log('[post-interactions][nutzap] handleNutzapClick:cancelled-by-user');
return;
}
console.log('[post-interactions][nutzap] handleNutzapClick:details', {
amountSats: details.amountSats,
hasComment: Boolean(String(details.comment || '').trim()),
shouldZap: Boolean(details.shouldZap)
});
const state = getPostState(postId);
if (!state.userLiked && currentPubkey) {
const interactionBar = itemEl.closest('.divPostInteractions');
const likeItem = interactionBar?.querySelector('.interaction-item.like');
if (likeItem) {
try {
await handleLikeClick(postId, postPubkey, currentPubkey, likeItem);
console.log('[post-interactions][nutzap] handleNutzapClick:auto-like:ok');
} catch (likeError) {
console.error('[post-interactions][nutzap] handleNutzapClick:auto-like:failed', likeError);
}
}
}
const shouldZap = details.shouldZap !== false;
if (!shouldZap) {
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
if (countEl) {
countEl.textContent = '';
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, 150);
}
return;
}
// --- Pre-flight balance check (Phase 5) --------------------------------
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
const preflightBalance = await fetchWalletBalanceSats();
if (preflightBalance !== null) {
if (preflightBalance < zapAmount) {
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
console.warn('[post-interactions][nutzap] handleNutzapClick:insufficient-balance', {
balance: preflightBalance, needed: zapAmount
});
if (countEl) {
countEl.textContent = 'low';
countEl.title = msg;
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, 2200);
}
window.alert(msg);
return;
}
const remaining = preflightBalance - zapAmount;
const tenPercent = Math.floor(preflightBalance * 0.10);
if (remaining > 0 && remaining < tenPercent) {
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
const proceed = window.confirm(confirmMsg);
if (!proceed) {
console.log('[post-interactions][nutzap] handleNutzapClick:declined-low-balance');
return;
}
}
}
// --- Send the nutzap (Cashu ecash) -------------------------------------
if (countEl) countEl.textContent = 'send…';
console.log('[post-interactions][nutzap] handleNutzapClick:sending-nutzap');
const nutzapResult = await walletSendNutzapFn({
amount: details.amountSats,
memo: details.comment,
targetPubkey: postPubkey,
eventId: postId,
recipientMints: nutzapSpec.mints,
recipientP2pk: nutzapSpec.p2pk,
recipientRelays: nutzapSpec.relays
});
console.log('[post-interactions][nutzap] handleNutzapClick:nutzap-ok', nutzapResult || {});
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
itemEl.classList.add('zap-success');
if (countEl) countEl.textContent = 'sent';
const sentAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
if (sentAmount > 0) {
state.nutzapTotal = Math.max(0, Number(state.nutzapTotal || 0)) + sentAmount;
updateNutzapButtonCount(itemEl, state);
}
setTimeout(() => {
itemEl.classList.remove('zap-success');
updateNutzapButtonCount(itemEl, state);
}, 1800);
// --- Post-nutzap balance refresh ---------------------------------------
refreshWalletBalanceDisplay().catch((refreshError) => {
console.warn('[post-interactions][nutzap] handleNutzapClick:balance-refresh-failed', refreshError?.message || refreshError);
});
} catch (error) {
const timeoutFailure = isZapTimeoutError(error);
const friendlyMessage = friendlyZapErrorMessage(error);
console.error('[post-interactions][nutzap] handleNutzapClick:failed', error);
if (countEl) {
countEl.textContent = timeoutFailure ? 'check' : 'fail';
countEl.title = friendlyMessage;
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, timeoutFailure ? 2800 : 2200);
}
window.alert(friendlyMessage);
itemEl.classList.remove('zap-success');
} finally {
clearInterval(zapPulseInterval);
itemEl.dataset.busy = '0';
if (!paymentSucceeded) {
itemEl.classList.remove('active', 'zap-pending', 'zap-pending-accent', 'zap-success');
updateIconState(iconContainer, 'nutzap', false);
} else {
itemEl.classList.remove('active', 'zap-pending-accent');
updateIconState(iconContainer, 'nutzap', true);
}
console.log('[post-interactions][nutzap] handleNutzapClick:finish');
}
}
/**
* Update the count display for an interaction item
* @param {HTMLElement} itemEl - The interaction item element
@@ -2308,6 +2734,8 @@ export function initInteractions(ndkFunctions = {}) {
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -2325,6 +2753,8 @@ export function initInteractions(ndkFunctions = {}) {
walletPayInvoiceFn = typeof walletPayInvoice === 'function' ? walletPayInvoice : null;
walletSendNutzapFn = typeof walletSendNutzap === 'function' ? walletSendNutzap : null;
walletFetchMintListFn = typeof walletFetchMintList === 'function' ? walletFetchMintList : null;
walletGetMintsFn = typeof walletGetMints === 'function' ? walletGetMints : null;
walletGetBalanceFn = typeof walletGetBalance === 'function' ? walletGetBalance : null;
getRelayDataFn = typeof getRelayData === 'function' ? getRelayData : null;
getUserSettingsFn = typeof getUserSettings === 'function' ? getUserSettings : null;
patchUserSettingsFn = typeof patchUserSettings === 'function' ? patchUserSettings : null;

View File

@@ -14,6 +14,8 @@ export function initPostCards(deps = {}) {
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS,
getUserSettings,
@@ -34,6 +36,8 @@ export function initPostCards(deps = {}) {
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -65,7 +69,9 @@ export function initPostCards(deps = {}) {
function handleUpdate(postId, state, event, currentPubkey) {
interactions.updateInteractionBar(postId, state);
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId)) {
// Only render inline comments if comments are visible (the new feed
// sets commentsVisible=false to avoid inline reply rendering).
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId) && interactions.getCommentsVisible?.()) {
interactions.addCommentToPost(postId, {
id: event.id,
pubkey: event.pubkey,

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.18",
"VERSION_NUMBER": "0.7.18",
"BUILD_DATE": "2026-06-14T12:07:35.533Z"
"VERSION": "v0.7.37",
"VERSION_NUMBER": "0.7.37",
"BUILD_DATE": "2026-06-26T01:29:31.321Z"
}

View File

@@ -249,6 +249,37 @@ function closeActiveZapModal(result, resolver) {
resolver(result);
}
/**
* Escape a string for safe insertion into HTML text/attribute content.
* @param {string} value
* @returns {string}
*/
const HTML_ESCAPE_RE = /[&<>"']/g;
function escapeHtml(value) {
const amp = '&' + 'amp;';
const lt = '&' + 'lt;';
const gt = '&' + 'gt;';
const quot = '&' + 'quot;';
const apos = '&' + '#39;';
const map = { '&': amp, '<': lt, '>': gt, '"': quot, "'": apos };
return String(value || '').replace(HTML_ESCAPE_RE, (ch) => map[ch]);
}
/**
* Lightning-only zap details dialog.
*
* Shows amount input, preset amounts, Cashu balance, and an insufficient-balance
* warning. No rail selector — Lightning is the only rail here. Nutzaps have
* their own dialog (`promptNutzapDetails`) and their own button.
*
* @param {Object} options
* @param {number} [options.defaultAmountSats=21]
* @param {string} [options.defaultComment='']
* @param {boolean} [options.defaultShouldZap=true]
* @param {Function} [options.onSaveDefault]
* @param {number|null} [options.balanceSats=null]
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
*/
export function promptZapDetails(options = {}) {
if (activeZapModalEl) {
return Promise.resolve(null);
@@ -258,7 +289,8 @@ export function promptZapDetails(options = {}) {
defaultAmountSats = 21,
defaultComment = '',
defaultShouldZap = true,
onSaveDefault = null
onSaveDefault = null,
balanceSats = null
} = options;
return new Promise((resolve) => {
@@ -268,11 +300,16 @@ export function promptZapDetails(options = {}) {
: 21;
const normalizedDefaultComment = String(defaultComment || '').trim();
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
: '';
const overlay = document.createElement('div');
overlay.className = 'zap-selector-overlay';
overlay.innerHTML = `
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send zap">
<div class="zap-selector-title">Send Zap</div>
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send Lightning zap">
<div class="zap-selector-title">⚡ Lightning Zap</div>
${balanceDisplay}
<div class="zap-selector-presets">
<button type="button" class="zap-preset" data-amount="21">⚡21</button>
<button type="button" class="zap-preset" data-amount="100">⚡100</button>
@@ -284,9 +321,10 @@ export function promptZapDetails(options = {}) {
Amount (sats)
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
</label>
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
<label class="zap-selector-label">
Comment (optional)
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${normalizedDefaultComment.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</textarea>
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${escapeHtml(normalizedDefaultComment)}</textarea>
</label>
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
@@ -307,6 +345,7 @@ export function promptZapDetails(options = {}) {
const cancelBtn = overlay.querySelector('.zap-cancel');
const enableZapEl = overlay.querySelector('.zap-selector-enable');
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
const parseCurrentValues = () => {
const amountSats = Number(amountEl?.value || 0);
@@ -315,11 +354,24 @@ export function promptZapDetails(options = {}) {
return { amountSats, comment, shouldZap };
};
const updateBalanceWarning = () => {
if (!warningEl) return;
const { amountSats } = parseCurrentValues();
const balance = Number(balanceSats);
const insufficient = Number.isFinite(balance)
&& balance >= 0
&& Number.isFinite(amountSats)
&& amountSats > balance;
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
warningEl.style.display = insufficient ? '' : 'none';
};
const selectPreset = (button) => {
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
if (amountEl && button?.dataset?.amount) {
amountEl.value = button.dataset.amount;
}
updateBalanceWarning();
};
presetButtons.forEach((button) => {
@@ -328,6 +380,7 @@ export function promptZapDetails(options = {}) {
amountEl?.addEventListener('input', () => {
presetButtons.forEach((btn) => btn.classList.remove('active'));
updateBalanceWarning();
});
saveDefaultBtn?.addEventListener('click', async () => {
@@ -390,6 +443,222 @@ export function promptZapDetails(options = {}) {
} else {
presetButtons.forEach((btn) => btn.classList.remove('active'));
}
updateBalanceWarning();
amountEl?.focus();
});
}
/**
* Nutzap (Cashu ecash) details dialog.
*
* Shows amount input, preset amounts, Cashu balance, the recipient's accepted
* mints (from kind 10019), and which shared mint will be used to send the
* ecash. Returns the user's choices or null if cancelled.
*
* @param {Object} options
* @param {number} [options.defaultAmountSats=21]
* @param {string} [options.defaultComment='']
* @param {boolean} [options.defaultShouldZap=true]
* @param {Function} [options.onSaveDefault]
* @param {number|null} [options.balanceSats=null]
* @param {string[]} [options.recipientMints=[]] - all mints on recipient's kind 10019
* @param {string[]} [options.sharedMints=[]] - mints shared with sender's wallet
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
*/
export function promptNutzapDetails(options = {}) {
if (activeZapModalEl) {
return Promise.resolve(null);
}
const {
defaultAmountSats = 21,
defaultComment = '',
defaultShouldZap = true,
onSaveDefault = null,
balanceSats = null,
recipientMints = [],
sharedMints = []
} = options;
const normalizedRecipientMints = Array.isArray(recipientMints)
? recipientMints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
const normalizedSharedMints = Array.isArray(sharedMints)
? sharedMints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
const primarySharedMint = normalizedSharedMints[0] || '';
return new Promise((resolve) => {
const initialAmount = Number(defaultAmountSats);
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
? Math.floor(initialAmount)
: 21;
const normalizedDefaultComment = String(defaultComment || '').trim();
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
: '';
const sharedMintInfoHtml = primarySharedMint
? `<div class="zap-selector-mint-info">Will send via ${escapeHtml(primarySharedMint)}</div>`
: '';
const recipientMintsListHtml = normalizedRecipientMints.length > 0
? `<div class="zap-selector-mint-info">
<div>Recipient accepts mints:</div>
<ul class="zap-selector-mint-list">
${normalizedRecipientMints.map((m) => {
const isShared = normalizedSharedMints.some(
(s) => s.replace(/\/+$/, '').toLowerCase() === m.replace(/\/+$/, '').toLowerCase()
);
return `<li class="zap-selector-mint-list-item${isShared ? ' shared' : ''}">${escapeHtml(m)}${isShared ? ' ✓' : ''}</li>`;
}).join('')}
</ul>
</div>`
: '';
const overlay = document.createElement('div');
overlay.className = 'zap-selector-overlay';
overlay.innerHTML = `
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send nutzap">
<div class="zap-selector-title">🥜 Nutzap</div>
${balanceDisplay}
<div class="zap-selector-presets">
<button type="button" class="zap-preset" data-amount="21">🥜21</button>
<button type="button" class="zap-preset" data-amount="100">🥜100</button>
<button type="button" class="zap-preset" data-amount="500">🥜500</button>
<button type="button" class="zap-preset" data-amount="1000">🥜1K</button>
<button type="button" class="zap-preset" data-amount="5000">🥜5K</button>
</div>
<label class="zap-selector-label">
Amount (sats)
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
</label>
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
${sharedMintInfoHtml}
${recipientMintsListHtml}
<label class="zap-selector-label">
Comment (optional)
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post 🥜">${escapeHtml(normalizedDefaultComment)}</textarea>
</label>
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
Send nutzap
</label>
<div class="zap-selector-actions">
<button type="button" class="zap-cancel">Cancel</button>
<button type="button" class="zap-save-default">Save as default</button>
<button type="button" class="zap-send">Send</button>
</div>
</div>
`;
const amountEl = overlay.querySelector('.zap-selector-amount');
const commentEl = overlay.querySelector('.zap-selector-comment');
const sendBtn = overlay.querySelector('.zap-send');
const saveDefaultBtn = overlay.querySelector('.zap-save-default');
const cancelBtn = overlay.querySelector('.zap-cancel');
const enableZapEl = overlay.querySelector('.zap-selector-enable');
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
const parseCurrentValues = () => {
const amountSats = Number(amountEl?.value || 0);
const comment = String(commentEl?.value || '').trim();
const shouldZap = Boolean(enableZapEl?.checked);
return { amountSats, comment, shouldZap };
};
const updateBalanceWarning = () => {
if (!warningEl) return;
const { amountSats } = parseCurrentValues();
const balance = Number(balanceSats);
const insufficient = Number.isFinite(balance)
&& balance >= 0
&& Number.isFinite(amountSats)
&& amountSats > balance;
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
warningEl.style.display = insufficient ? '' : 'none';
};
const selectPreset = (button) => {
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
if (amountEl && button?.dataset?.amount) {
amountEl.value = button.dataset.amount;
}
updateBalanceWarning();
};
presetButtons.forEach((button) => {
button.addEventListener('click', () => selectPreset(button));
});
amountEl?.addEventListener('input', () => {
presetButtons.forEach((btn) => btn.classList.remove('active'));
updateBalanceWarning();
});
saveDefaultBtn?.addEventListener('click', async () => {
const { amountSats, comment } = parseCurrentValues();
if (!Number.isFinite(amountSats) || amountSats <= 0) {
amountEl?.focus();
return;
}
if (typeof onSaveDefault === 'function') {
try {
await onSaveDefault({ amountSats, comment });
saveDefaultBtn.textContent = 'Saved';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
} catch (error) {
console.error('[zaps] Save as default failed:', error?.message || error, error);
saveDefaultBtn.textContent = 'Save failed';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
}
return;
}
console.warn('[zaps] Save as default unavailable: onSaveDefault callback not provided');
saveDefaultBtn.textContent = 'Unavailable';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
});
sendBtn?.addEventListener('click', () => {
const { amountSats, comment, shouldZap } = parseCurrentValues();
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
amountEl?.focus();
return;
}
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
});
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) closeActiveZapModal(null, resolve);
});
const onEscape = (event) => {
if (event.key !== 'Escape') return;
document.removeEventListener('keydown', onEscape);
closeActiveZapModal(null, resolve);
};
document.addEventListener('keydown', onEscape);
activeZapModalEl = overlay;
document.body.appendChild(overlay);
const defaultPreset = presetButtons.find((btn) => Number(btn.dataset?.amount || 0) === normalizedDefaultAmount);
if (defaultPreset) {
selectPreset(defaultPreset);
} else {
presetButtons.forEach((btn) => btn.classList.remove('active'));
}
updateBalanceWarning();
amountEl?.focus();
});
}
@@ -506,3 +775,170 @@ export function extractZapComment(event) {
}
return '';
}
// =============================================================================
// ZAP CAPABILITY RESOLUTION (Phase 3 — feed-upgrade)
// =============================================================================
//
// resolveZapCapabilities(pubkey) determines, at a glance, which zap rails are
// available for a given recipient pubkey:
// - canLightning: recipient has a lud16 Lightning address (NIP-57 via Cashu melt)
// - canNutzap: recipient has a kind 10019 mint list (NIP-61) AND at least
// one of those mints overlaps with the sender's wallet mints
// - sharedMints: the overlapping mint URLs (empty when no overlap)
// - nutzapMints: all mints declared on the recipient's kind 10019
// - lud16: the recipient's Lightning address (or null)
//
// Results are cached per-pubkey in an in-memory Map with a 5-minute TTL so
// repeated feed renders do not refetch. The cache is best-effort and safe to
// clear on page navigation (see clearZapCapabilitiesCache).
const ZAP_CAP_CACHE_TTL_MS = 5 * 60 * 1000;
const zapCapCache = new Map(); // pubkey -> { result, expiresAt }
/**
* Clear the in-memory zap-capability cache.
* Safe to call on page navigation; subsequent calls will refetch.
*/
export function clearZapCapabilitiesCache() {
zapCapCache.clear();
}
/**
* Normalize a mint URL for comparison (trim + strip trailing slashes).
* @param {string} url
* @returns {string}
*/
function normalizeMintForCompare(url) {
return String(url || '').trim().replace(/\/+$/, '').toLowerCase();
}
/**
* Resolve which zap rails are available for a given pubkey.
*
* @param {string} pubkey - The recipient's pubkey (hex)
* @param {Object} options
* @param {Function} [options.fetchProfile] - profile-cache fetcher (kind 0)
* @param {Function} [options.fetchMintList] - walletFetchMintList(pubkey) -> { hasMintList, mints, ... }
* @param {Function} [options.getSenderMints] - walletGetMints() -> { mints: [...] }
* @param {boolean} [options.useCache=true] - whether to consult the in-memory cache
* @param {string} [options.logPrefix='[zaps]']
* @returns {Promise<{canLightning:boolean, canNutzap:boolean, sharedMints:string[], nutzapMints:string[], lud16:string|null, hasMintList:boolean}>}
*/
export async function resolveZapCapabilities(pubkey, options = {}) {
const {
fetchProfile: fetchProfileFn,
fetchMintList,
getSenderMints,
useCache = true,
logPrefix = '[zaps]'
} = options;
const recipient = String(pubkey || '').trim();
if (!recipient) {
return {
canLightning: false,
canNutzap: false,
sharedMints: [],
nutzapMints: [],
lud16: null,
hasMintList: false
};
}
// --- Cache lookup ---------------------------------------------------------
const now = Date.now();
if (useCache) {
const cached = zapCapCache.get(recipient);
if (cached && cached.expiresAt > now) {
return cached.result;
}
}
// --- Lightning capability (lud16 from kind 0) -----------------------------
let lud16 = null;
let canLightning = false;
if (typeof fetchProfileFn === 'function') {
try {
const profile = await fetchProfileFn(recipient);
const normalized = normalizeLud16(profile?.lud16);
if (normalized) {
lud16 = normalized;
canLightning = true;
}
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: profile fetch failed`, error?.message || error);
}
}
// --- Nutzap capability (kind 10019 + shared mint overlap) -----------------
let nutzapMints = [];
let sharedMints = [];
let canNutzap = false;
let hasMintList = false;
if (typeof fetchMintList === 'function') {
try {
const mintList = await fetchMintList(recipient);
const rawMints = Array.isArray(mintList?.mints)
? mintList.mints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
nutzapMints = Array.from(new Set(rawMints));
hasMintList = Boolean(mintList?.hasMintList) && nutzapMints.length > 0;
if (hasMintList) {
// Determine the sender's wallet mints to compute overlap.
let senderMints = [];
if (typeof getSenderMints === 'function') {
try {
const senderResult = await getSenderMints();
senderMints = Array.isArray(senderResult?.mints)
? senderResult.mints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: sender mints fetch failed`, error?.message || error);
}
}
if (senderMints.length > 0) {
const senderSet = new Set(senderMints.map(normalizeMintForCompare));
sharedMints = nutzapMints.filter((m) => senderSet.has(normalizeMintForCompare(m)));
canNutzap = sharedMints.length > 0;
} else {
// No sender wallet mints available yet — cannot confirm a shared mint,
// so we do not claim canNutzap. The recipient still has a mint list,
// which the UI may render in a muted state.
canNutzap = false;
}
}
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: mint list fetch failed`, error?.message || error);
}
}
const result = {
canLightning,
canNutzap,
sharedMints,
nutzapMints,
lud16,
hasMintList
};
// --- Cache store ----------------------------------------------------------
zapCapCache.set(recipient, {
result,
expiresAt: Date.now() + ZAP_CAP_CACHE_TTL_MS
});
console.log(`${logPrefix} resolveZapCapabilities:ok`, {
recipient: recipient.slice(0, 8) + '…',
canLightning,
canNutzap,
hasMintList,
sharedMints: sharedMints.length,
nutzapMints: nutzapMints.length
});
return result;
}

View File

@@ -294,6 +294,15 @@ let lightwalletHasWallet = false;
let spendingHistoryLastFetchAtMs = 0;
let spendingHistoryFetchPromise = null;
// Nutzap mint list (kind 10019) cache for followed users — proactively
// fetched on startup so resolveZapCapabilities() can hit IDB instead of
// going to relays for every followed author.
const NUTZAP_MINTLIST_DB_NAME = 'ndk-nutzap-mintlists';
const NUTZAP_MINTLIST_DB_VERSION = 1;
const NUTZAP_MINTLIST_STORE = 'mintlists'; // keyPath: pubkey
const NUTZAP_MINTLIST_PREFETCH_BATCH = 50; // max authors per fetchEvents call
const NUTZAP_MINTLIST_PREFETCH_TTL_MS = 6 * 60 * 60 * 1000; // 6h freshness guard
// Relay activity tracking
const RELAY_EVENT_LOG_LIMIT = 200;
const RELAY_EVENTS_DB_NAME = 'relay-events';
@@ -600,6 +609,89 @@ async function openRelayEventsDb() {
});
}
// -----------------------------------------------------------------------------
// Nutzap mint list (kind 10019) IDB cache for followed users
// -----------------------------------------------------------------------------
async function openNutzapMintListDb() {
if (!HAS_INDEXEDDB || !INDEXEDDB_USABLE) {
throw new Error('IndexedDB unavailable or unhealthy');
}
return await new Promise((resolve, reject) => {
const req = indexedDB.open(NUTZAP_MINTLIST_DB_NAME, NUTZAP_MINTLIST_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.createObjectStore(NUTZAP_MINTLIST_STORE, { keyPath: 'pubkey' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
/**
* Store a parsed kind 10019 mint list for a pubkey in IDB.
* @param {string} pubkey
* @param {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }} entry
*/
async function putNutzapMintListToDb(pubkey, entry) {
if (!pubkey) return;
try {
const db = await openNutzapMintListDb();
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.close();
return;
}
await new Promise((resolve, reject) => {
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readwrite');
tx.objectStore(NUTZAP_MINTLIST_STORE).put({
pubkey,
...entry,
cachedAt: Date.now()
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (error) {
console.warn('[Worker] putNutzapMintListToDb failed', error?.message || error);
}
}
/**
* Read a cached kind 10019 mint list for a pubkey from IDB.
* Returns null when missing or stale (older than NUTZAP_MINTLIST_PREFETCH_TTL_MS).
* @param {string} pubkey
* @returns {Promise<Object|null>}
*/
async function getNutzapMintListFromDb(pubkey) {
if (!pubkey) return null;
try {
const db = await openNutzapMintListDb();
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.close();
return null;
}
const row = await new Promise((resolve) => {
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readonly');
const req = tx.objectStore(NUTZAP_MINTLIST_STORE).get(pubkey);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
db.close();
if (!row) return null;
if (Date.now() - Number(row.cachedAt || 0) > NUTZAP_MINTLIST_PREFETCH_TTL_MS) {
return null; // stale
}
return row;
} catch (error) {
console.warn('[Worker] getNutzapMintListFromDb failed', error?.message || error);
return null;
}
}
function serializeRelayEventData(data) {
if (data === null || data === undefined) return null;
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') return data;
@@ -1723,6 +1815,51 @@ async function processLightwalletTokenEvent(tokenEvent, pubkey) {
}
}
let legacyWalletMigrationAttempted = false;
/**
* One-time migration: if the user has an old-format kind 17375 wallet event
* (proprietary {mints, nutzap} object instead of NIP-60 tag-array), republish
* it in the standard format so other NIP-60 clients (Amethyst, NDK) can read it.
* The migration is fire-and-forget — publishDirectProofs() writes the new
* tag-array format and NIP-09 deletes the old event.
*/
async function migrateLegacyWalletEventIfNeeded(pubkey, walletEvents = []) {
if (legacyWalletMigrationAttempted) return;
if (!pubkey || !messageSigner) return;
const events = Array.isArray(walletEvents) ? walletEvents : [];
if (events.length === 0) return;
for (const evt of events) {
if (!evt?.content) continue;
try {
const decrypted = await messageSigner.requestFromPage('nip44Decrypt', {
senderPubkey: pubkey,
ciphertext: evt.content
});
const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext;
if (!plaintext) continue;
const parsed = JSON.parse(plaintext);
// Legacy format is an object; NIP-60 standard is an array.
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
console.log('[Worker] NIP-60 migration: detected legacy wallet event, republishing in tag-array format');
legacyWalletMigrationAttempted = true;
try {
await publishDirectProofs({ traceId: 'nip60-migration' });
console.log('[Worker] NIP-60 migration: republish complete');
} catch (err) {
console.warn('[Worker] NIP-60 migration: republish failed:', err?.message || err);
}
return;
}
} catch (_error) {
// Ignore decrypt/parse failures — might be a different encryption scheme
}
}
// No legacy events found; mark as checked so we don't re-scan every startup.
legacyWalletMigrationAttempted = true;
}
async function hydrateLightwalletFromFetchedEvents(pubkey, walletEvents = [], tokenEvents = [], deletionEvents = []) {
lightwalletHasWallet = Array.isArray(walletEvents) && walletEvents.length > 0;
await hydrateNutzapKeyFromWalletEvents(pubkey, walletEvents);
@@ -1737,6 +1874,12 @@ async function hydrateLightwalletFromFetchedEvents(pubkey, walletEvents = [], to
hydrateDirectProofStore();
broadcastLightwalletBalance('startup-fetch');
// Fire-and-forget: migrate legacy {mints,nutzap} wallet events to NIP-60
// tag-array format so other clients (Amethyst, NDK) can read our wallet.
void migrateLegacyWalletEventIfNeeded(pubkey, walletEvents).catch((err) => {
console.warn('[Worker] NIP-60 migration check failed:', err?.message || err);
});
}
async function fetchStartupEventDownloads(pubkey) {
@@ -1816,6 +1959,22 @@ async function fetchStartupEventDownloads(pubkey) {
await processIncomingNutzaps(eventsByKey.kind9321 || [], { context: 'startup-fetch' });
broadcastStartupStatus('Startup downloads complete', { stage: 'startup-downloads', state: 'done', counts });
// Tier 1: proactively prefetch kind 10019 nutzap mint lists for all
// followed users so resolveZapCapabilities() can hit IDB. This runs in the
// background and must not block startup completion, so we do not await it.
try {
const kind3Events = Array.from(eventsByKey.kind3 || []);
const latestKind3 = kind3Events
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0] || null;
if (latestKind3) {
prefetchFollowedNutzapMintLists(latestKind3).catch((error) => {
console.warn('[Worker] background kind 10019 prefetch failed', error?.message || error);
});
}
} catch (prefetchError) {
console.warn('[Worker] kind 10019 prefetch scheduling failed', prefetchError?.message || prefetchError);
}
}
function ensureStartupWalletSubscriptions(pubkey) {
@@ -2184,6 +2343,59 @@ function ensureDirectNutzapP2pk() {
};
}
/**
* Parse the decrypted content of a kind 17375 Cashu wallet event.
*
* NIP-60 standard format (what we now write and what Amethyst/NDK expect):
* [["mint","https://mint.example.com"],["privkey","<hex>"]]
*
* Legacy format (what this project previously wrote — kept for backwards compat):
* {"mints":["https://mint.example.com"],"nutzap":{"privkey":"<hex>","p2pk":"<pubkey>"}}
*
* Returns { mints: string[], privkey: string|null, p2pk: string|null, isLegacy: boolean }
*/
function parseWalletContent(plaintext) {
if (!plaintext) return { mints: [], privkey: null, p2pk: null, isLegacy: false };
const parsed = JSON.parse(plaintext);
if (!parsed || typeof parsed !== 'object') {
return { mints: [], privkey: null, p2pk: null, isLegacy: false };
}
// Standard NIP-60 tag-array format: [["mint",url],["privkey",hex]]
if (Array.isArray(parsed)) {
const mints = [];
let privkey = null;
let p2pk = null;
for (const tag of parsed) {
if (!Array.isArray(tag) || tag.length < 2) continue;
if (tag[0] === 'mint' && typeof tag[1] === 'string') {
mints.push(normalizeMintUrl(tag[1]));
} else if (tag[0] === 'privkey' && typeof tag[1] === 'string') {
privkey = normalizeHexKey(tag[1]);
} else if (tag[0] === 'p2pk' && typeof tag[1] === 'string') {
p2pk = normalizeCashuP2pk(tag[1]);
}
}
return { mints: mints.filter(Boolean), privkey, p2pk, isLegacy: false };
}
// Legacy object format: { mints: [...], nutzap: { privkey, p2pk } }
const mints = Array.isArray(parsed.mints)
? parsed.mints.map(normalizeMintUrl).filter(Boolean)
: [];
const privkey = normalizeHexKey([
parsed?.nutzap?.privkey,
parsed?.nutzapPrivkey,
parsed?.nutzap_private_key
].find(Boolean) || '');
const p2pk = normalizeCashuP2pk([
parsed?.nutzap?.p2pk,
parsed?.nutzapP2pk,
parsed?.nutzap_p2pk
].find(Boolean) || '');
return { mints, privkey: privkey || null, p2pk: p2pk || null, isLegacy: true };
}
async function hydrateNutzapKeyFromWalletEvents(pubkey, walletEvents = []) {
if (!messageSigner || !pubkey) return;
@@ -2200,20 +2412,9 @@ async function hydrateNutzapKeyFromWalletEvents(pubkey, walletEvents = []) {
const plaintext = typeof decrypted === 'string' ? decrypted : decrypted?.plaintext;
if (!plaintext) continue;
const parsed = JSON.parse(plaintext);
if (!parsed || typeof parsed !== 'object') continue;
const candidatePrivkey = normalizeHexKey([
parsed?.nutzap?.privkey,
parsed?.nutzapPrivkey,
parsed?.nutzap_private_key
].find(Boolean) || '');
const candidateP2pk = normalizeCashuP2pk([
parsed?.nutzap?.p2pk,
parsed?.nutzapP2pk,
parsed?.nutzap_p2pk
].find(Boolean) || '');
const parsed = parseWalletContent(plaintext);
const candidatePrivkey = parsed.privkey;
const candidateP2pk = parsed.p2pk;
if (!candidatePrivkey && !candidateP2pk) continue;
@@ -2750,22 +2951,29 @@ async function publishDirectProofs(debugContext = null) {
}
log('delete old 7375 done');
const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]);
if (directNutzapP2pk) {
mintTags.push(['pubkey', directNutzapP2pk]);
// Ensure a nutzap privkey exists before building the wallet payload.
// If the user's old 17375 didn't have one (or hydration failed),
// this auto-generates one so it's always included in the republish.
try {
ensureDirectNutzapP2pk();
} catch (err) {
log('ensureDirectNutzapP2pk failed during publish', { error: err?.message || String(err) });
}
const walletPayloadObj = {
mints: getDirectWalletMints(),
...(directNutzapPrivateKeyHex
? {
nutzap: {
privkey: directNutzapPrivateKeyHex,
p2pk: directNutzapP2pk || null
}
}
: {})
};
// NIP-60 standard: public "mint" tags are visible on the event;
// the privkey lives in the encrypted content as a "privkey" tag.
// The "pubkey" tag does NOT belong on kind 17375 (it belongs on
// kind 10019 NutzapInfoEvent). Removed for NIP-60 compliance.
const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]);
// NIP-60 standard content format: a JSON array of tag arrays.
// [["mint","https://..."],["privkey","<hex>"]]
// This matches NDK's payloadForEvent() and Amethyst's
// CashuWalletEvent.build() so other NIP-60 clients can read our wallet.
const walletPayloadObj = [
...getDirectWalletMints().map((url) => ['mint', url]),
...(directNutzapPrivateKeyHex ? [['privkey', directNutzapPrivateKeyHex]] : [])
];
const walletPayload = JSON.stringify(walletPayloadObj);
log('encrypt wallet payload start', { mintTagCount: mintTags.length });
const encryptedWalletResp = await messageSigner.requestFromPage('nip44Encrypt', {
@@ -3105,6 +3313,142 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
}
}
/**
* Parse a kind 3 (contact list) event into a de-duplicated array of followed
* pubkeys (the 'p' tags).
* @param {Object|null|undefined} kind3Event
* @returns {string[]}
*/
function extractFollowedPubkeysFromKind3(kind3Event) {
if (!kind3Event || !Array.isArray(kind3Event.tags)) return [];
const pubkeys = new Set();
for (const tag of kind3Event.tags) {
if (Array.isArray(tag) && tag[0] === 'p' && tag[1] && String(tag[1]).length >= 32) {
pubkeys.add(String(tag[1]));
}
}
return Array.from(pubkeys);
}
/**
* Parse a raw kind 10019 event into the mint-list shape used by the cache.
* @param {Object} event
* @returns {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }}
*/
function parseKind10019Event(event) {
const mints = Array.from(new Set((event.tags || [])
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)));
const relays = Array.from(new Set((event.tags || [])
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
.map((tag) => String(tag[1]).trim())
.filter((url) => /^wss?:\/\//i.test(url))));
const p2pkTag = (event.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
return {
hasMintList: mints.length > 0,
mints,
relays,
p2pk,
eventId: event.id || null,
created_at: Number(event.created_at || 0)
};
}
/**
* Proactively fetch kind 10019 (nutzap mint lists) for all followed pubkeys
* discovered in the user's kind 3 contact list, and cache the results in
* IndexedDB. This is the Tier 1 fetch strategy: followed users are pre-warmed
* on startup so resolveZapCapabilities() can hit IDB instead of relays.
*
* Runs in the background and never rejects — failures are logged only.
*
* @param {Object|null|undefined} kind3Event - the user's kind 3 contact list
*/
async function prefetchFollowedNutzapMintLists(kind3Event) {
if (!ndk) return;
const pubkeys = extractFollowedPubkeysFromKind3(kind3Event);
if (pubkeys.length === 0) {
broadcastStartupStatus('No followed pubkeys to prefetch nutzap mint lists for', {
stage: 'kind10019-prefetch',
state: 'skipped'
});
return;
}
broadcastStartupStatus(`Prefetching nutzap mint lists (kind 10019) for ${pubkeys.length} followed users...`, {
stage: 'kind10019-prefetch',
state: 'start',
count: pubkeys.length
});
const startedAt = Date.now();
let stored = 0;
try {
// Fetch in batches to keep relay filters reasonable.
for (let i = 0; i < pubkeys.length; i += NUTZAP_MINTLIST_PREFETCH_BATCH) {
const batch = pubkeys.slice(i, i + NUTZAP_MINTLIST_PREFETCH_BATCH);
try {
const events = await ndk.fetchEvents({
kinds: [10019],
authors: batch
});
const eventList = Array.from(events || []);
// Group events by author and keep only the latest per pubkey.
const latestByAuthor = new Map();
for (const event of eventList) {
const prev = latestByAuthor.get(event.pubkey);
if (!prev || Number(event.created_at || 0) > Number(prev.created_at || 0)) {
latestByAuthor.set(event.pubkey, event);
}
}
for (const [author, event] of latestByAuthor) {
const parsed = parseKind10019Event(event);
await putNutzapMintListToDb(author, parsed);
stored++;
}
// Record an explicit "no mint list" entry for followed pubkeys
// that returned nothing, so we can short-circuit future lookups
// within the TTL window.
for (const author of batch) {
if (!latestByAuthor.has(author)) {
await putNutzapMintListToDb(author, {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null,
created_at: 0
});
}
}
} catch (batchError) {
console.warn('[Worker] kind 10019 prefetch batch failed', batchError?.message || batchError);
}
}
broadcastStartupStatus(`Nutzap mint list prefetch done (${stored} stored)`, {
stage: 'kind10019-prefetch',
state: 'done',
count: stored,
durationMs: Date.now() - startedAt
});
} catch (error) {
broadcastStartupStatus('Nutzap mint list prefetch failed', {
stage: 'kind10019-prefetch',
state: 'failed',
error: error?.message || String(error),
durationMs: Date.now() - startedAt
});
console.warn('[Worker] prefetchFollowedNutzapMintLists failed', error?.message || error);
}
}
async function handleWalletFetchMintList(requestId, targetPubkey, port) {
try {
if (!ndk) throw new Error('NDK not initialized');
@@ -3114,6 +3458,30 @@ async function handleWalletFetchMintList(requestId, targetPubkey, port) {
throw new Error('Target pubkey is required');
}
// Tier 2 lazy fetch: consult the IDB cache (populated by the Tier 1
// startup prefetch) before hitting relays. A fresh cache hit avoids a
// round-trip for non-followed-but-recently-seen pubkeys too.
try {
const cached = await getNutzapMintListFromDb(author);
if (cached) {
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: Boolean(cached.hasMintList),
mints: Array.isArray(cached.mints) ? cached.mints : [],
relays: Array.isArray(cached.relays) ? cached.relays : [],
p2pk: cached.p2pk || null,
eventId: cached.eventId || null,
created_at: Number(cached.created_at || 0)
}
});
return;
}
} catch (_cacheError) {
// Fall through to relay fetch on any cache error.
}
const events = await ndk.fetchEvents({
kinds: [10019],
authors: [author],
@@ -3124,43 +3492,35 @@ async function handleWalletFetchMintList(requestId, targetPubkey, port) {
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0];
if (!latest) {
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null
}
});
const emptyEntry = {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null,
created_at: 0
};
// Cache the negative result so repeated lookups are cheap.
putNutzapMintListToDb(author, emptyEntry).catch(() => {});
port.postMessage({ type: 'response', requestId, data: emptyEntry });
return;
}
const mints = Array.from(new Set((latest.tags || [])
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)));
const parsed = parseKind10019Event(latest);
const relays = Array.from(new Set((latest.tags || [])
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
.map((tag) => String(tag[1]).trim())
.filter((url) => /^wss?:\/\//i.test(url))));
const p2pkTag = (latest.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
// Cache the relay result for future lookups.
putNutzapMintListToDb(author, parsed).catch(() => {});
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: mints.length > 0,
mints,
relays,
p2pk,
eventId: latest.id || null,
created_at: Number(latest.created_at || 0)
hasMintList: parsed.hasMintList,
mints: parsed.mints,
relays: parsed.relays,
p2pk: parsed.p2pk,
eventId: parsed.eventId,
created_at: parsed.created_at
}
});
} catch (error) {
@@ -4213,6 +4573,97 @@ async function handleWalletCheckProofs(requestId, port) {
}
}
/**
* Lightweight republish of ONLY the kind 17375 wallet event.
*
* Kind 17375 is a replaceable event (NIP-60, d-tag = ""), so publishing a
* new one automatically supersedes the old one on relays — no NIP-09
* deletions needed. This skips the heavy publishDirectProofs() which
* rewrites all 7375 token events too (100+ signing round-trips).
*
* Just: ensure privkey → build tag-array payload → encrypt → sign → publish.
*/
async function handleWalletRepublish(requestId, port) {
try {
if (!ndk || !currentPubkey) {
port.postMessage({ type: 'response', requestId, error: 'NDK not ready' });
return;
}
await ensureDirectWalletLoaded();
// Ensure a nutzap privkey exists (auto-generate if missing)
try {
ensureDirectNutzapP2pk();
} catch (err) {
console.warn('[Worker] handleWalletRepublish: ensureDirectNutzapP2pk failed:', err?.message || err);
}
const NDKEvent = getNDKEventCtor();
if (!NDKEvent) {
port.postMessage({ type: 'response', requestId, error: 'NDKEvent constructor not available' });
return;
}
// Build NIP-60 tag-array payload: [["mint",url],["privkey",hex]]
const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]);
const walletPayloadObj = [
...getDirectWalletMints().map((url) => ['mint', url]),
...(directNutzapPrivateKeyHex ? [['privkey', directNutzapPrivateKeyHex]] : [])
];
const walletPayload = JSON.stringify(walletPayloadObj);
// NIP-44 encrypt the payload
const encryptedWalletResp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext: walletPayload
});
const encryptedWallet = typeof encryptedWalletResp === 'string' ? encryptedWalletResp : encryptedWalletResp?.ciphertext;
if (!encryptedWallet) {
port.postMessage({ type: 'response', requestId, error: 'Failed to encrypt wallet payload' });
return;
}
// Build and publish the new 17375 event (replaceable — supersedes old one)
const walletEvent = new NDKEvent(ndk, {
kind: 17375,
tags: mintTags,
content: encryptedWallet,
created_at: Math.floor(Date.now() / 1000)
});
await signEventWithMessageSigner(walletEvent);
const relaySet = await walletEvent.publish();
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
trackRelayWrite(relay.url);
}
}
console.log('[Worker] handleWalletRepublish: 17375 published', {
relayCount: relaySet ? relaySet.size : 0,
mintCount: mintTags.length,
hasPrivkey: Boolean(directNutzapPrivateKeyHex)
});
const payload = getWalletBalancePayload();
port.postMessage({
type: 'response',
requestId,
data: {
success: true,
mints: getDirectWalletMints(),
relayCount: relaySet ? relaySet.size : 0,
...payload
}
});
} catch (error) {
console.warn('[Worker] handleWalletRepublish failed:', error?.message || error);
port.postMessage({ type: 'response', requestId, error: error.message || 'Republish failed' });
}
}
function handleWalletShutdown(requestId, port) {
try {
clearWalletListeners();
@@ -4226,6 +4677,7 @@ function handleWalletShutdown(requestId, port) {
directNutzapP2pk = null;
directMintLocalUpdatedAt.clear();
spendingHistoryLastFetchAtMs = 0;
legacyWalletMigrationAttempted = false;
spendingHistoryFetchPromise = null;
broadcastWalletStatus();
port.postMessage({ type: 'response', requestId, data: { success: true } });
@@ -5942,6 +6394,7 @@ function handleDisconnect() {
directNutzapP2pk = null;
spendingHistoryLastFetchAtMs = 0;
spendingHistoryFetchPromise = null;
legacyWalletMigrationAttempted = false;
}
// Get relay data for relays page
@@ -6407,7 +6860,11 @@ self.onconnect = (event) => {
case 'walletCheckProofs':
await handleWalletCheckProofs(requestId, port);
break;
case 'walletRepublish':
await handleWalletRepublish(requestId, port);
break;
default:
console.warn('[Worker] Unknown message type:', type);
}

1273
www/post-feed.html Normal file

File diff suppressed because it is too large Load Diff