Add NIP-57 zap tracking with totals, sort, and auto-close

Phase 1 — Sign zap requests:
- When the LNURL endpoint advertises allowsNostr + nostrPubkey and the
  tipper has NIP-07, sign a kind-9734 zap request and pass it as the
  nostr= param to the pay callback. Tags include e (event id), a
  (naddr coords) and p (recipient) for feed-card / preview tips, or
  just p for profile tips.
- Silent fallback to plain LNURL-pay for non-zap endpoints or
  signed-out users. Tips never silently fail.
- Tiny "⚡ Tracked zap" badge in the invoice view when the zap path
  engaged, so QR / copy users know their tip will count.

Phase 2 — Aggregate and display:
- Parse kind-9735 receipts via the embedded description tag (NIP-57
  amount) or a bolt11 amount fallback (with multiplier table).
- _zapCache aggregates by event id and by author pubkey; receipts are
  deduped by event id.
- Feed enrichment phase batch-queries receipts by #e for visible
  designs; profile-page opens query by #p for the author and by #e
  for each design in the grid.
- Feed cards display a "⚡ <total>" badge in the actions row.
- Design preview meta line gets the same badge.
- Profile page header shows "⚡ X sats received" under the npub.

Sort and visibility:
- New "⚡ Zapped" tab in both the Community modal and the welcome
  modal. Sort is pure zap-total with date as the tiebreaker.
- Existing Popular sort now leads with zap totals, falling back to
  reactions, trust, and date.
- isPreviewBroken now flags designs pre-emptively when the preview
  URL is missing (for modes that require artwork) or uses a non-http
  scheme, so broken designs sink to the bottom on first paint
  instead of waiting for an actual image-load failure.
- Broken-image set persists to localStorage so detected breakage
  survives reloads.

Payment detection:
- After a tracked invoice is issued, a background poller queries
  kind-9735 events with #p=recipient + since=now. When a receipt
  arrives whose embedded zap request matches the tipper's pubkey,
  the event id, and the amount (within 10% tolerance), the modal
  shows "✓ Payment received! Closing…" and auto-closes 1.2s later.
  Polling stops after 5 minutes (invoice expiry), on close, or on
  "New amount".
- WebLN auto-pay button appears in the invoice view when
  window.webln is available; one click pays via the extension and
  closes the modal on success. Rejections are handled quietly so
  the QR / copy paths remain available.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-18 14:46:31 -04:00
1 parent e33d15f41f
commit c9cf28fbb9
2 files changed
+758 -24

No files matched your search

+241
View File
@@ -0,0 +1,241 @@
# Tracked Zaps (NIP-57)
## Goals
Turn today's untracked LNURL-pay tips into **on-relay-recorded zaps** so we can sum them per design, per author, and per author's profile, and rank by them.
- A zap that fires today should generate a kind-9735 receipt on Nostr that *anyone* can later read.
- A design card should be able to display `⚡ N sats` and a count of zappers.
- The feed should be sortable by zap totals.
- Profile pages should show the total zaps that author has received (and, later, sent).
### Product goals
1. Make tipping discoverable. If a design has gotten 10k sats, that's a signal future viewers should see.
2. Preserve the existing plain-LNURL-pay flow as a fallback so tips never silently fail.
3. Stay Nostr-native: use NIP-57 directly, no analytics service, no central scoreboard.
### Non-goals (v1)
- Backfilling tips from the pre-tracking era. Those LN payments are gone from a Nostr perspective.
- Punishing or hiding designs with **few** zaps. We surface signal, not anti-signal.
- Showing **who** zapped (privacy + UI clutter); v1 only sums amounts. A "Zappers" list can come later.
- Anonymous zaps (`anon` tag in NIP-57). Adds branch complexity. Defer.
- WebLN auto-pay (would also need a NIP-57 path; deferrable).
- Zap leaderboards across all designs / authors. Once aggregation works, leaderboards are a small UI; not v1.
---
## Current state
- The existing tip flow (`openTipModal` → `fetchLnurlPayMetadata` → `fetchLnurlInvoice`) does **plain LNURL-pay**: fetches an amount-locked bolt11 and renders QR + copy. Nothing about that payment lands on Nostr.
- LN providers that support zaps publish a kind-9735 **iff** the LNURL-pay request carries a `nostr=<encoded-zap-request>` param. The plain flow omits it, so receipts are never created for our tips.
---
## NIP-57 in our terms
### What a zap request looks like (kind 9734)
```json
{
"kind": 9734,
"pubkey": "<tipper hex>",
"created_at": 1700000000,
"tags": [
["relays", "wss://relay.damus.io", "wss://nos.lol"],
["amount", "21000"], // millisats
["p", "<author hex>"], // recipient
["e", "<design event id>"], // OPTIONAL — present for design zaps; omitted for pure profile zaps
["a", "30078:<author>:<d-tag>"] // OPTIONAL — naddr coords for replaceable kinds
],
"content": "Optional comment"
}
```
Sent to the LNURL-pay endpoint as:
```
GET <callback>?amount=<msats>&nostr=<URL-encoded JSON of signed event>
```
### What a zap receipt looks like (kind 9735)
Published by the LN service (or by Alby's zap.land etc.) after the invoice settles:
```json
{
"kind": 9735,
"pubkey": "<LN-service zap pubkey>", // matches metadata.nostrPubkey
"created_at": 1700000000,
"tags": [
["bolt11", "lnbc..."],
["description", "<JSON string of original 9734>"],
["p", "<recipient>"],
["e", "<event id>"],
["a", "30078:..."]
],
"content": ""
}
```
Amount is parsed from the `bolt11` tag (HRP `lnbc<amount><multiplier>` encoding) or from the `amount` tag in the embedded zap request.
### Metadata flag
The LNURL-pay metadata endpoint advertises support via:
```json
{
"allowsNostr": true,
"nostrPubkey": "<hex pubkey of the zap-publisher>"
}
```
Both must be present and non-empty to use the zap path. `nostrPubkey` is also what we use to **verify** that incoming kind-9735 receipts are legitimate (the receipt's `pubkey` must match).
---
## Phase 1 — Sign zap requests when supported
### Decision tree at "Get invoice" click time
```
LNURL metadata advertises allowsNostr + nostrPubkey?
├── No → plain LNURL-pay (today's flow)
└── Yes
Tipper has NIP-07 signer available?
├── No → plain LNURL-pay (today's flow)
└── Yes
Sign kind-9734 with the right tags →
?nostr=<encoded> appended to callback →
bolt11 returned + QR rendered as before
```
The user-visible UX is identical for the picker / invoice view. The only difference is a tiny `⚡ Tracked zap` badge next to the amount once we know the zap path is engaged (so a user paying via QR/copy knows their tip will count).
### Tag rules per entry point
| Entry point | Tags on the 9734 |
|---|---|
| **Profile page Tip chip** | `p`=author, `relays`, `amount`. No `e`, no `a`. Pure profile zap. |
| **Feed card Tip button** | `p`=author, `e`=design event id, `a`=`<kind>:<author>:<d-tag>` if d-tag exists, `relays`, `amount`. |
| **Design preview Tip button** | Same as feed card. |
This lets us aggregate by author OR by design just from filters.
### Plumbing changes
- `openTipModal` accepts an optional `targetEvent: { id, kind, dTag, authorPubkey }`. Profile-page openings omit it; feed-card / preview openings pass it.
- New helper `buildZapRequestEvent({ recipient, amount, eventId, kind, dTag, relays, comment })` returns the unsigned 9734.
- New helper `tryZapPath(metadata, target, amount)` returns `{ encodedZapRequest, zapPubkey }` or `null` if any precondition fails.
- `fetchLnurlInvoice` gains an optional `nostr` param; when present, appends `&nostr=<urlencoded>` to the callback.
- `getTipInvoiceFlow` calls `tryZapPath` first; on success, signs and includes; on failure, falls through to plain.
### What never changes
- The bolt11 we hand the user is identical in structure either way.
- Fallback paths for malformed addresses, CORS errors, min/max range remain exactly as today.
- No new modal states. Just a tiny optional badge.
### Out of scope (still) in Phase 1
- Reading 9735s. That's Phase 2.
- Showing the tipper's pubkey to the recipient. NIP-57 receipts already do this naturally if the recipient queries — we don't add UI here.
- Encrypted zaps / private zaps.
### Acceptance
- A signed-in user tipping a zap-supporting author (e.g., Wallet of Satoshi) sees the same invoice/QR flow as before — but minutes later a kind-9735 receipt with our zap request inside appears on the relays.
- A signed-out user tipping the same author still gets a working bolt11 — the LN provider returns one even without `nostr=`, just no receipt.
- A signed-in user tipping a non-zap LN endpoint (`allowsNostr` absent or false) silently falls back — no error, no extra UI noise.
---
## Phase 2 — Aggregate & display
### Fetch strategy
- When the community feed renders, batch-query receipts for visible designs:
`{ kinds: [9735], '#e': [eventId1, eventId2, …], limit: 500 }`
- For author totals (profile page), query:
`{ kinds: [9735], '#p': [authorPubkey], limit: 500 }`
- Stream results into a `_zapCache` Map keyed by event id (and a separate one keyed by author for profile totals).
- Validate each receipt:
- Parse the embedded `description` JSON → must be a kind-9734.
- Receipt's `pubkey` must equal the recipient's metadata `nostrPubkey` if we know it (we may not, so this check is opportunistic, not required for v1).
- Sum `amount` from the zap request tag (or parse from `bolt11` as a fallback).
### Display
- **Feed card**: small chip `⚡ 5.2k` in the actions row when total > 0.
- **Profile page**: under the avatar, optional line `⚡ 84k sats received`.
- **Design preview**: same chip in the preview meta line.
Counts use abbreviated formatting (`1.2k`, `21k`, `1.4M`).
### Sort
- Feed sort dropdown gets a new option: **Most zapped**. Sums received within a sliding window (default: forever; could later add 7-day / 30-day).
- Ranking is purely client-side from the visible window of receipts. No global leaderboard.
### Caching
- Same TTL as feed reactions (~2 min). Cheap to refetch.
- Soft-mod cache and zap cache live independently. Both keyed by event id.
### Acceptance
- A design that has received zaps shows the total on its card and in its preview.
- Sorting the feed by "Most zapped" reorders cards correctly within the loaded set.
- Profile pages show the author's lifetime zap-received total.
---
## Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Tipper not signed in → zap untracked | Show a one-line hint in the tip modal: "Sign in with Nostr to make this tip count toward this design's totals." Optional. |
| LN provider doesn't support zaps | Silent fallback. Don't surface failure as user-facing error — the tip still works. |
| Wallet publishes the 9735 to relays we don't query | Fetch from a broad relay set (use `relays` tag from the receipt's embedded zap request, plus the viewer's relays). |
| Forged zap receipts (someone publishes a fake 9735) | v1 trusts what we see. v2 adds the `pubkey === metadata.nostrPubkey` check by caching service pubkeys per recipient. |
| Sum inflated by duplicate receipts | Dedupe receipts by `id` (event id), not by zap request. |
| bolt11 amount mismatch with zap request amount | Trust the bolt11 amount (it's what actually got paid); zap-request amount is the *intent*. |
| Querying receipts for 50 designs at once → big subscription | Use existing `queryRelays` streaming with `onEvent`; batch in groups of 20 if relays balk. |
| Race between Phase 1 sign + endpoint response | Standard try/catch; on signing failure, fall back to plain flow. |
---
## Phased delivery (concrete)
### Phase 1 — Sign zap requests (this PR)
1. Add `buildZapRequestEvent`, `tryZapPath`, `signNostrEvent` reuse.
2. Extend `openTipModal` to accept `{ targetEvent }`.
3. Thread `targetEvent` through feed-card and preview Tip buttons.
4. Update `getTipInvoiceFlow` to attempt zap path; fall back on any failure.
5. Tiny `⚡ Tracked zap` badge on the invoice view when zap path engaged.
**Ship gate**: a tip from a signed-in user to a zap-enabled provider produces a kind-9735 receipt on the recipient's relays within a minute.
### Phase 2 — Aggregate and display
1. `_zapCache` keyed by event id and by author.
2. Fetch receipts during community feed loads and on profile page open.
3. Parse + validate + sum.
4. Render zap totals on feed cards / preview / profile.
5. Add "Most zapped" sort option.
**Ship gate**: zap totals appear on cards within a session; sort order changes when the new sort is selected.
---
## Open questions
1. **Should we publish the kind-9734 ourselves before sending to LNURL-pay?**
NIP-57 says clients MAY publish it themselves; not required. Recommendation: **no** — the receipt's `description` tag already proves it existed, and double-publishing pollutes relays.
2. **Use a fixed default-zap amount (e.g., 21 sats)?**
Not v1. Picker already handles this. Default zap amounts can land later as a profile preference.
3. **Should we surface "you zapped this" on cards (visible only to the zapper)?**
Nice but requires querying receipts whose embedded request has `pubkey === me`. Cheap; could be a small v2 add.
4. **Verify receipt service pubkey strictly?**
Recommend v1 = loose verification (any kind-9735 with a parseable embedded request counts). v2 = strict (cache `metadata.nostrPubkey` per recipient and require receipt.pubkey to match).
5. **Should the picker grow a "comment" box for zap-supporting endpoints?**
Many endpoints accept comments via `commentAllowed`. v1 = no, to keep UI quiet. v2 = optional.
---
## Acceptance summary
### Phase 1
- [ ] Profile-page tip flow signs and sends a zap request when supported; plain LNURL-pay otherwise.
- [ ] Feed-card tip flow does the same with `e` and `a` tags pointing at the design.
- [ ] Design-preview tip flow same.
- [ ] All three entry points fall back silently for non-zap endpoints and signed-out users.
- [ ] A `⚡ Tracked zap` badge appears in the invoice view when the zap path engaged.
### Phase 2
- [ ] Feed cards show `⚡ <total>` when receipts exist.
- [ ] Profile pages show author totals.
- [ ] Design preview shows the same chip.
- [ ] "Most zapped" sort option works.
+517 -24
View File
@@ -1531,6 +1531,8 @@
body.nostr-logged-in .feedTrustToggle{display:flex}
.layerLockTag{display:inline-block;font-size:.58rem;font-weight:800;letter-spacing:.06em;color:var(--muted);background:rgba(127,127,127,.15);border:1px solid var(--line);border-radius:3px;padding:0 4px;margin-right:6px;vertical-align:1px}
.feedTrustBadge{font-size:.62rem;background:rgba(0,184,230,.12);color:var(--accent);border:1px solid rgba(0,184,230,.25);border-radius:4px;padding:1px 5px;margin-left:5px;white-space:nowrap;flex-shrink:0}
.feedCardZapBadge{font-size:.72rem;font-weight:600;color:#b45309;background:rgba(251,191,36,.18);border:1px solid rgba(251,191,36,.4);border-radius:10px;padding:2px 8px;margin-left:auto;white-space:nowrap}
body.dark .feedCardZapBadge{color:#fbbf24;background:rgba(251,191,36,.12);border-color:rgba(251,191,36,.3)}
body.dark .feedTrustBadge{background:rgba(0,217,255,.1);border-color:rgba(0,217,255,.2)}
.feedCard.feedCardBlurred .feedCardThumb img,
.feedCard.feedCardBlurred .feedCardThumb canvas,
@@ -2522,6 +2524,7 @@
<div class="welcomeTabs" role="tablist" aria-label="Browse">
<button type="button" class="welcomeTab active" id="welcomeTabNew" data-welcome-tab="New" role="tab">New</button>
<button type="button" class="welcomeTab" id="welcomeTabPopular" data-welcome-tab="Popular" role="tab">Popular</button>
<button type="button" class="welcomeTab" id="welcomeTabZapped" data-welcome-tab="Zapped" role="tab">⚡ Zapped</button>
<button type="button" class="welcomeTab" id="welcomeTabSearch" data-welcome-tab="Search" role="tab" style="display:none">Search results</button>
</div>
<div class="feedWall" id="welcomeResults"></div>
@@ -2555,6 +2558,7 @@
<div class="modalTabs">
<button type="button" class="modalTab" id="tabNew">New</button>
<button type="button" class="modalTab" id="tabPopular">Popular</button>
<button type="button" class="modalTab" id="tabZapped">⚡ Zapped</button>
<button type="button" class="modalTab" id="tabSearch">Search</button>
<button type="button" class="modalTab" id="tabMine">My Designs</button>
</div>
@@ -2627,6 +2631,40 @@
<div class="feedWall" id="feedWallPopular"></div>
</div>
<div id="panelZapped" style="display:none">
<div class="feedTypeFilter">
<button class="feedTypeBtn active" data-tab="Zapped" data-type="any">All</button>
<button class="feedTypeBtn" data-tab="Zapped" data-type="cover">Case Covers</button>
<button class="feedTypeBtn" data-tab="Zapped" data-type="disc">Disc Sheets</button>
<button class="feedTypeBtn" data-tab="Zapped" data-type="jewel">Jewel Inserts</button>
<button class="feedTypeBtn" data-tab="Zapped" data-type="disc-design">Disc Designs</button>
<button class="feedTypeBtn" data-tab="Zapped" data-type="customart">Custom Art</button>
<select id="feedCategoryZapped" class="feedCategorySelect categorySelect" data-tab="Zapped" aria-label="Category filter"></select>
<select id="feedLanguageZapped" class="feedCategorySelect feedLanguageSelect" data-tab="Zapped" aria-label="Language filter">
<option value="">Any language</option>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="pt">Portuguese</option>
<option value="it">Italian</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="zh">Chinese</option>
<option value="other">Other</option>
</select>
<label class="feedTrustToggle" title="Filter by web-of-trust">Trust:
<select id="trustFilterZapped">
<option value="all">All</option>
<option value="trusted">Follows</option>
<option value="extended">Follows + Extended</option>
</select>
</label>
</div>
<div class="feedLoadStatus" id="feedStatusZapped" hidden></div>
<div class="feedWall" id="feedWallZapped"></div>
</div>
<div id="panelSearch" style="display:none">
<div class="searchGrid">
<div><label for="nostrQuery">Title or IMDb ID</label><input id="nostrQuery" type="text" placeholder="e.g. The Matrix or tt0133093" /></div>
@@ -2723,6 +2761,7 @@
<h2 id="profileName">Loading…</h2>
<p id="profileNip05" class="hint" style="display:none"></p>
<p id="profileNpub" class="hint profileNpub"></p>
<p id="profileZapTotal" class="hint" style="display:none;margin-top:4px;font-weight:600;color:#b45309"></p>
</div>
<div id="profileActions" class="profileActions"></div>
</div>
@@ -2780,6 +2819,7 @@
<code id="tipBolt11"></code>
<button type="button" id="tipCopyBtn" aria-label="Copy invoice">📋</button>
</div>
<button type="button" id="tipWeblnBtn" class="primary" style="display:none;width:100%;margin-bottom:10px">⚡ Pay with WebLN</button>
<p class="hint" style="margin:0 0 14px;font-size:.78rem">Scan the QR with a Lightning wallet, or copy the invoice and paste it into your wallet to send the tip.</p>
<div class="buttonRow" style="margin:0;justify-content:space-between">
<button type="button" id="tipBackBtn">← New amount</button>
@@ -7061,18 +7101,49 @@
}
const _feedCache = { events: null, reactions: {}, profiles: {}, fetchedAt: 0, loading: false, relayKey: '', relayProgress: { responded: 0, total: 0 }, phase: '', softMod: new Map(), softModRevealed: new Set(), brokenImages: new Set() };
const BROKEN_IMAGES_STORAGE_KEY = 'casewrap:brokenImages:v1';
const BROKEN_IMAGES_MAX = 500;
function loadBrokenImagesFromStorage() {
const set = new Set();
try {
const raw = localStorage.getItem(BROKEN_IMAGES_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed)) for (const url of parsed) if (typeof url === 'string') set.add(url);
} catch {}
return set;
}
function saveBrokenImagesToStorage(set) {
try {
const arr = [...set].slice(-BROKEN_IMAGES_MAX);
localStorage.setItem(BROKEN_IMAGES_STORAGE_KEY, JSON.stringify(arr));
} catch {}
}
const _feedCache = { events: null, reactions: {}, profiles: {}, fetchedAt: 0, loading: false, relayKey: '', relayProgress: { responded: 0, total: 0 }, phase: '', softMod: new Map(), softModRevealed: new Set(), brokenImages: loadBrokenImagesFromStorage() };
function markImageBroken(url) {
if (!url) return;
_feedCache.brokenImages.add(url);
saveBrokenImagesToStorage(_feedCache.brokenImages);
if (typeof _currentFeedRerender === 'function') {
clearTimeout(markImageBroken._t);
markImageBroken._t = setTimeout(() => { try { _currentFeedRerender(); } catch {} }, 200);
}
}
// Modes where a primary image is expected. Custom art is allowed to be
// text/color-only so we don't flag empty previews there.
const MODES_REQUIRE_IMAGE = new Set(['cover', 'disc', 'jewel', 'disc-design']);
function isPreviewBroken(row) {
return !!(row?.preview && _feedCache.brokenImages.has(row.preview));
if (!row) return false;
const preview = row.preview;
// No URL at all + this mode expects one → effectively a missing image.
if (!preview && MODES_REQUIRE_IMAGE.has(row.mode)) return true;
if (!preview) return false;
// Non-http(s) scheme → can't load. Penalize pre-emptively.
if (!/^https?:\/\//i.test(preview)) return true;
return _feedCache.brokenImages.has(preview);
}
let _currentFeedRerender = null;
const FEED_PROGRESS_DEBOUNCE_MS = 150;
@@ -7232,6 +7303,11 @@
await fetchSoftModSignal(relays, templateEvents);
notifyFeedProgress();
}
// Zap receipts (NIP-57). Streams kind-9735 events and updates _zapCache.
fetchZapsForEvents(ids, relays).then(mutated => {
if (mutated) notifyFeedProgress();
}).catch(() => {});
}
_feedCache.fetchedAt = Date.now();
} finally {
@@ -7241,7 +7317,7 @@
}
}
const _feedState = { filterNew: 'any', filterPopular: 'any', filterMine: 'any', categoryNew: '', categoryPopular: '', categoryMine: '', languageNew: '', languagePopular: '', languageMine: '', trustFilterNew: 'all', trustFilterPopular: 'all' };
const _feedState = { filterNew: 'any', filterPopular: 'any', filterZapped: 'any', filterMine: 'any', categoryNew: '', categoryPopular: '', categoryZapped: '', categoryMine: '', languageNew: '', languagePopular: '', languageZapped: '', languageMine: '', trustFilterNew: 'all', trustFilterPopular: 'all', trustFilterZapped: 'all' };
const TIER_WEIGHT = { self: 0, trusted: 1, extended: 2, unknown: 3 };
@@ -7262,10 +7338,13 @@
if (!loggedIn) {
_feedState.trustFilterNew = 'all';
_feedState.trustFilterPopular = 'all';
_feedState.trustFilterZapped = 'all';
const selNew = $('trustFilterNew');
const selPop = $('trustFilterPopular');
const selZap = $('trustFilterZapped');
if (selNew) selNew.value = 'all';
if (selPop) selPop.value = 'all';
if (selZap) selZap.value = 'all';
}
const softModBlock = $('softModDetails');
if (softModBlock) softModBlock.style.display = loggedIn ? '' : 'none';
@@ -7747,6 +7826,13 @@
// Kick off the designs grid load in parallel.
loadProfileDesigns(decoded.pubkey, decoded.relays);
// Fetch zap receipts targeting this author; render the running total.
fetchZapsForAuthor(decoded.pubkey, decoded.relays).then(() => {
if (_profilePagePubkey !== decoded.pubkey) return;
renderProfileZapTotal(decoded.pubkey);
}).catch(() => {});
renderProfileZapTotal(decoded.pubkey);
}
function closeProfilePage(opts = {}) {
@@ -8080,6 +8166,17 @@
}
}
function renderProfileZapTotal(pubkey) {
const el = $('profileZapTotal');
if (!el) return;
const msats = zapTotalForAuthor(pubkey);
if (msats <= 0) { el.style.display = 'none'; el.textContent = ''; return; }
const sats = Math.floor(msats / 1000);
el.textContent = `⚡ ${formatSats(sats)} sats received`;
el.title = `${sats.toLocaleString()} sats zapped to this author across all designs`;
el.style.display = '';
}
function renderProfileDesigns(pubkey) {
if (_profilePagePubkey !== pubkey) return;
const filterEl = $('profileDesignFilter');
@@ -8123,6 +8220,13 @@
for (const e of [...designEvents, ...deletionEvents]) byId.set(e.id, e);
_feedCache.events = [...byId.values()];
renderProfileDesigns(pubkey);
// Fetch per-design zap receipts in the background and rerender when they arrive.
const eventIds = designEvents.map(e => e.id).filter(Boolean);
if (eventIds.length) {
fetchZapsForEvents(eventIds, relays).then(mutated => {
if (mutated && _profilePagePubkey === pubkey) renderProfileDesigns(pubkey);
}).catch(() => {});
}
} catch {
if (_profilePagePubkey !== pubkey) return;
const hasAny = Array.isArray(_feedCache.events) && _feedCache.events.some(e => e.pubkey === pubkey && e.kind === 30078);
@@ -8176,9 +8280,11 @@
return j;
}
async function fetchLnurlInvoice(callback, msats) {
async function fetchLnurlInvoice(callback, msats, opts = {}) {
const sep = callback.includes('?') ? '&' : '?';
const url = `${callback}${sep}amount=${msats}`;
let url = `${callback}${sep}amount=${msats}`;
if (opts.nostr) url += `&nostr=${encodeURIComponent(opts.nostr)}`;
if (opts.lnurl) url += `&lnurl=${encodeURIComponent(opts.lnurl)}`;
const r = await fetch(url, { headers: { Accept: 'application/json' } });
if (!r.ok) throw new Error(`Invoice fetch failed (${r.status})`);
const j = await r.json();
@@ -8187,6 +8293,49 @@
return j.pr;
}
function buildZapRequestEvent({ recipient, amountMsats, eventId, kind, dTag, relays, comment }) {
const tags = [];
const relayTag = ['relays', ...(relays || []).filter(Boolean)];
if (relayTag.length > 1) tags.push(relayTag);
tags.push(['amount', String(amountMsats)]);
tags.push(['p', recipient]);
if (eventId) tags.push(['e', eventId]);
if (kind != null && recipient && dTag) tags.push(['a', `${kind}:${recipient}:${dTag}`]);
return {
kind: 9734,
created_at: Math.floor(Date.now() / 1000),
tags,
content: comment || ''
};
}
async function tryZapPath(metadata, target, amountMsats) {
if (!metadata?.allowsNostr || !metadata?.nostrPubkey) return null;
if (!window.nostr) return null;
try {
const tipperPubkey = await getNostrPublicKey();
if (!tipperPubkey) return null;
const recipient = target?.authorPubkey;
if (!recipient) return null;
const relays = splitRelayList(state.community?.relays || '').slice(0, 5);
const unsigned = buildZapRequestEvent({
recipient,
amountMsats,
eventId: target.eventId || '',
kind: target.kind ?? null,
dTag: target.dTag || '',
relays,
comment: ''
});
unsigned.pubkey = tipperPubkey;
const signed = await signNostrEvent(unsigned);
if (!signed?.sig) return null;
return { encoded: JSON.stringify(signed), zapServicePubkey: metadata.nostrPubkey };
} catch {
return null;
}
}
function renderQrInto(el, text) {
if (typeof qrcode !== 'function') { el.textContent = 'QR library unavailable'; return; }
el.innerHTML = '';
@@ -8205,14 +8354,223 @@
el.innerHTML = qr.createSvgTag({ scalable: true });
}
let _tipTarget = null; // { lnurlpUrl, label, lud16 }
// ---- NIP-57 zap receipts: parse + aggregate ----
const _zapCache = {
byEvent: new Map(), // eventId -> { msats, count }
byAuthor: new Map(), // pubkey -> { msats, count }
receiptIds: new Set(), // dedup receipts by id
fetchedForEvents: new Set(), // event ids whose receipts we've queried
fetchedForAuthors: new Set() // author pubkeys whose receipts we've queried
};
function parseBolt11Msats(bolt11) {
if (!bolt11 || typeof bolt11 !== 'string') return 0;
// Match prefix like "lnbc100n1..." or "lnbc1...". Group 1 = digits, group 2 = optional multiplier.
const m = bolt11.toLowerCase().match(/^ln(?:bc|tb|bcrt)(\d+)([munp])?1/);
if (!m) return 0;
const value = parseInt(m[1], 10);
if (!Number.isFinite(value) || value <= 0) return 0;
const multiplier = m[2] || '';
// msats per unit: bare = 1e11, m = 1e8, u = 1e5, n = 1e2, p = 1e-1
const perUnit = multiplier === 'm' ? 1e8
: multiplier === 'u' ? 1e5
: multiplier === 'n' ? 1e2
: multiplier === 'p' ? 0.1
: 1e11;
return Math.round(value * perUnit);
}
function parseZapReceiptAmount(receipt) {
if (!receipt || !Array.isArray(receipt.tags)) return 0;
// Prefer the amount tag in the embedded zap request — it's the user's intent and
// is always in millisats per NIP-57.
const descTag = receipt.tags.find(t => t[0] === 'description');
if (descTag && descTag[1]) {
try {
const req = JSON.parse(descTag[1]);
if (req && Array.isArray(req.tags)) {
const amtTag = req.tags.find(t => t[0] === 'amount');
const amt = amtTag ? parseInt(amtTag[1], 10) : 0;
if (Number.isFinite(amt) && amt > 0) return amt;
}
} catch { /* fall through to bolt11 */ }
}
const bolt11Tag = receipt.tags.find(t => t[0] === 'bolt11');
if (bolt11Tag && bolt11Tag[1]) return parseBolt11Msats(bolt11Tag[1]);
return 0;
}
function ingestZapReceipt(receipt) {
if (!receipt?.id || _zapCache.receiptIds.has(receipt.id)) return false;
const msats = parseZapReceiptAmount(receipt);
if (msats <= 0) return false;
_zapCache.receiptIds.add(receipt.id);
const eTag = (receipt.tags || []).find(t => t[0] === 'e' && t[1]);
const pTag = (receipt.tags || []).find(t => t[0] === 'p' && t[1]);
if (eTag) {
const entry = _zapCache.byEvent.get(eTag[1]) || { msats: 0, count: 0 };
entry.msats += msats;
entry.count += 1;
_zapCache.byEvent.set(eTag[1], entry);
}
if (pTag) {
const entry = _zapCache.byAuthor.get(pTag[1]) || { msats: 0, count: 0 };
entry.msats += msats;
entry.count += 1;
_zapCache.byAuthor.set(pTag[1], entry);
}
return true;
}
function zapTotalForEvent(eventId) {
return _zapCache.byEvent.get(eventId)?.msats || 0;
}
function zapTotalForAuthor(pubkey) {
return _zapCache.byAuthor.get(pubkey)?.msats || 0;
}
function formatSats(sats) {
if (sats >= 1e6) return (sats / 1e6).toFixed(sats >= 1e7 ? 0 : 1).replace(/\.0$/, '') + 'M';
if (sats >= 1000) return (sats / 1000).toFixed(sats >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k';
return String(sats);
}
function formatMsatsAsSats(msats) {
const sats = Math.floor((msats || 0) / 1000);
return formatSats(sats);
}
async function fetchZapsForEvents(eventIds, hintRelays = []) {
const fresh = (eventIds || []).filter(id => id && !_zapCache.fetchedForEvents.has(id));
if (!fresh.length) return false;
for (const id of fresh) _zapCache.fetchedForEvents.add(id);
const userRelays = splitRelayList(state.community?.relays || '');
const relays = [...new Set([...(hintRelays || []), ...userRelays])];
let mutated = false;
// Batch in groups of 30 ids per subscription to avoid oversized filters.
const CHUNK = 30;
for (let i = 0; i < fresh.length; i += CHUNK) {
const ids = fresh.slice(i, i + CHUNK);
try {
const events = await queryRelays(relays, { kinds: [9735], '#e': ids, limit: 500 }, {
timeoutMs: 4000,
onEvent: ev => { if (ingestZapReceipt(ev)) mutated = true; }
});
for (const ev of events || []) { if (ingestZapReceipt(ev)) mutated = true; }
} catch { /* keep going */ }
}
return mutated;
}
async function fetchZapsForAuthor(pubkey, hintRelays = []) {
if (!pubkey || _zapCache.fetchedForAuthors.has(pubkey)) return false;
_zapCache.fetchedForAuthors.add(pubkey);
const userRelays = splitRelayList(state.community?.relays || '');
const relays = [...new Set([...(hintRelays || []), ...userRelays])];
let mutated = false;
try {
const events = await queryRelays(relays, { kinds: [9735], '#p': [pubkey], limit: 500 }, {
timeoutMs: 4000,
onEvent: ev => { if (ingestZapReceipt(ev)) mutated = true; }
});
for (const ev of events || []) { if (ingestZapReceipt(ev)) mutated = true; }
} catch { /* ignore */ }
return mutated;
}
let _tipTarget = null; // { lnurlpUrl, label, lud16, targetEvent }
let _tipSelectedSats = 0;
let _tipMetadata = null;
let _tipPaymentWatch = null; // { token, cancel }
function stopTipPaymentWatch() {
if (!_tipPaymentWatch) return;
try { _tipPaymentWatch.cancel?.(); } catch {}
_tipPaymentWatch = null;
}
function startTipPaymentWatch({ tipperPubkey, recipientPubkey, sinceSec, amountMsats, eventId }) {
stopTipPaymentWatch();
const token = { active: true };
const since = Math.max(0, (sinceSec || Math.floor(Date.now() / 1000)) - 5);
const userRelays = splitRelayList(state.community?.relays || '');
const POLL_MS = 4000;
const TIMEOUT_MS = 5 * 60 * 1000;
const startedAt = Date.now();
const matches = (ev) => {
if (!ev || ev.kind !== 9735) return false;
// Match the recipient's p tag.
const pTag = (ev.tags || []).find(t => t[0] === 'p' && t[1]);
if (!pTag || pTag[1] !== recipientPubkey) return false;
// Parse the embedded zap request and verify it's ours.
const descTag = (ev.tags || []).find(t => t[0] === 'description');
if (!descTag?.[1]) return false;
try {
const req = JSON.parse(descTag[1]);
if (!req || req.kind !== 9734) return false;
if (tipperPubkey && req.pubkey !== tipperPubkey) return false;
// If we know the event id we asked to zap, verify it matches.
if (eventId) {
const reqE = (req.tags || []).find(t => t[0] === 'e' && t[1]);
if (!reqE || reqE[1] !== eventId) return false;
}
// Amount sanity check — within 10% tolerance to allow fee math.
const reqAmtTag = (req.tags || []).find(t => t[0] === 'amount');
const reqAmt = reqAmtTag ? parseInt(reqAmtTag[1], 10) : 0;
if (reqAmt > 0 && amountMsats > 0 && Math.abs(reqAmt - amountMsats) > amountMsats * 0.1) return false;
return true;
} catch { return false; }
};
const onSuccess = (ev) => {
if (!token.active) return;
token.active = false;
ingestZapReceipt(ev);
const status = $('tipPaymentStatus');
if (status) { status.textContent = '✓ Payment received! Closing…'; status.style.color = 'var(--accent)'; }
setTimeout(() => {
if (_tipPaymentWatch === watch) closeTipModal();
}, 1200);
};
const poll = async () => {
if (!token.active) return;
if (Date.now() - startedAt > TIMEOUT_MS) {
token.active = false;
const status = $('tipPaymentStatus');
if (status) status.textContent = 'No receipt yet — close when you\'re done.';
return;
}
try {
const events = await queryRelays(userRelays, {
kinds: [9735], '#p': [recipientPubkey], since, limit: 60
}, { timeoutMs: 2500 });
if (!token.active) return;
for (const ev of events || []) {
if (matches(ev)) { onSuccess(ev); return; }
}
} catch { /* keep polling */ }
if (!token.active) return;
setTimeout(poll, POLL_MS);
};
const watch = { token, cancel: () => { token.active = false; } };
_tipPaymentWatch = watch;
setTimeout(poll, 1500); // first poll after a brief delay so the receipt has time to arrive
return watch;
}
function openTipModal(opts = {}) {
const { lnurlpUrl, label, lud16 } = opts;
const { lnurlpUrl, label, lud16, targetEvent } = opts;
if (!lnurlpUrl) return;
_tipTarget = { lnurlpUrl, label: label || 'Recipient', lud16: lud16 || '' };
_tipTarget = {
lnurlpUrl,
label: label || 'Recipient',
lud16: lud16 || '',
targetEvent: targetEvent || null
};
_tipSelectedSats = 0;
_tipMetadata = null;
const modal = $('tipModal');
@@ -8246,6 +8604,7 @@
}
function closeTipModal() {
stopTipPaymentWatch();
const modal = $('tipModal');
if (modal) { modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); }
_tipTarget = null;
@@ -8289,18 +8648,50 @@
return;
}
const msats = sats * 1000;
const bolt11 = await fetchLnurlInvoice(meta.callback, msats);
showTipInvoiceView(bolt11, sats);
// Attempt NIP-57 zap path; falls back to plain LNURL-pay on any failure.
const zap = await tryZapPath(meta, _tipTarget.targetEvent, msats);
const issuedAt = Math.floor(Date.now() / 1000);
const bolt11 = await fetchLnurlInvoice(meta.callback, msats, zap ? { nostr: zap.encoded } : {});
// If we took the zap path, watch for the kind-9735 receipt and auto-close on success.
let watch = null;
if (zap && _tipTarget?.targetEvent?.authorPubkey) {
const tipperPubkey = await getNostrPublicKey().catch(() => '');
watch = startTipPaymentWatch({
tipperPubkey,
recipientPubkey: _tipTarget.targetEvent.authorPubkey,
sinceSec: issuedAt,
amountMsats: msats,
eventId: _tipTarget.targetEvent.eventId || ''
});
}
showTipInvoiceView(bolt11, sats, !!zap, watch);
} catch (err) {
if (errEl) errEl.textContent = String(err?.message || err);
if (getBtn) { getBtn.disabled = false; getBtn.textContent = 'Get invoice'; }
}
}
function showTipInvoiceView(bolt11, sats) {
function showTipInvoiceView(bolt11, sats, tracked = false, paymentWatch = null) {
$('tipPickerView').style.display = 'none';
$('tipInvoiceView').style.display = '';
$('tipInvoiceAmount').textContent = `${sats.toLocaleString()} sats`;
const amtEl = $('tipInvoiceAmount');
amtEl.textContent = `${sats.toLocaleString()} sats`;
if (tracked) {
const badge = document.createElement('span');
badge.textContent = ' · ⚡ Tracked zap';
badge.style.cssText = 'color:var(--accent);font-weight:600';
badge.title = 'Signed with your Nostr key — this tip will appear in zap totals';
amtEl.appendChild(badge);
}
if (paymentWatch) {
const status = document.createElement('span');
status.id = 'tipPaymentStatus';
status.style.cssText = 'display:block;margin-top:4px;font-size:.78rem;color:var(--muted)';
status.textContent = 'Waiting for payment…';
amtEl.appendChild(status);
}
const qrEl = $('tipQr');
const link = $('tipQrLink');
if (link) link.href = `lightning:${bolt11}`;
@@ -8314,9 +8705,42 @@
setTimeout(() => { if (copyBtn) copyBtn.textContent = '📋'; }, 1200);
};
}
// WebLN auto-pay: one-click for desktop users with Alby (or similar).
const weblnBtn = $('tipWeblnBtn');
if (weblnBtn) {
if (typeof window.webln !== 'undefined' && window.webln) {
weblnBtn.style.display = '';
weblnBtn.disabled = false;
weblnBtn.textContent = '⚡ Pay with WebLN';
weblnBtn.onclick = async () => {
weblnBtn.disabled = true;
weblnBtn.textContent = 'Paying…';
try {
if (typeof window.webln.enable === 'function') await window.webln.enable();
await window.webln.sendPayment(bolt11);
// Success: nudge the status text, then close.
const status = $('tipPaymentStatus');
if (status) { status.textContent = '✓ Payment sent! Closing…'; status.style.color = 'var(--accent)'; }
setTimeout(() => closeTipModal(), 800);
} catch (err) {
weblnBtn.disabled = false;
weblnBtn.textContent = '⚡ Pay with WebLN';
const status = $('tipPaymentStatus');
const message = (err?.message || '').toLowerCase();
if (status && !message.includes('reject') && !message.includes('cancel')) {
status.textContent = `WebLN failed: ${err?.message || 'unknown error'}. Use the QR or copy instead.`;
status.style.color = '';
}
}
};
} else {
weblnBtn.style.display = 'none';
}
}
}
function resetTipPicker() {
stopTipPaymentWatch();
$('tipInvoiceView').style.display = 'none';
$('tipPickerView').style.display = '';
const errEl = $('tipPickerError');
@@ -8716,6 +9140,14 @@
const tipBtn = makeFeedCardTipButton(row);
actions.append(makeReactionBtn(row), prevBtn, btn, blockBtn, reportBtn);
if (tipBtn) actions.appendChild(tipBtn);
const zapMsats = zapTotalForEvent(row.e?.id || '');
if (zapMsats > 0) {
const zapBadge = document.createElement('span');
zapBadge.className = 'feedCardZapBadge';
zapBadge.textContent = `⚡ ${formatMsatsAsSats(zapMsats)}`;
zapBadge.title = `${Math.floor(zapMsats / 1000).toLocaleString()} sats zapped to this design`;
actions.appendChild(zapBadge);
}
el.append(thumb, meta, actions);
el.addEventListener('click', () => {
if (el.classList.contains('feedCardBlurred')) return;
@@ -8731,6 +9163,7 @@
const lnurlpUrl = resolveLnurlPayUrl(profile);
if (!lnurlpUrl) return null;
const lnAddress = (profile?.lud16 || '').trim();
const dTag = (row.e?.tags || []).find(t => t[0] === 'd' && t[1])?.[1] || '';
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '⚡ Tip';
@@ -8738,7 +9171,17 @@
btn.addEventListener('click', e => {
e.stopPropagation();
const displayName = profile?.display_name || profile?.name || shortPubkey(pk);
openTipModal({ lnurlpUrl, label: displayName, lud16: lnAddress });
openTipModal({
lnurlpUrl,
label: displayName,
lud16: lnAddress,
targetEvent: {
authorPubkey: pk,
eventId: row.e?.id || '',
kind: row.e?.kind || 30078,
dTag
}
});
});
return btn;
}
@@ -8840,13 +9283,13 @@
function switchModalTab(tab) {
_nostrBrowseTab = tab;
for (const t of ['New', 'Popular', 'Search', 'Mine']) {
for (const t of ['New', 'Popular', 'Zapped', 'Search', 'Mine']) {
const tabEl = $(`tab${t}`);
const panelEl = $(`panel${t}`);
if (tabEl) tabEl.classList.toggle('active', t === tab);
if (panelEl) panelEl.style.display = t === tab ? '' : 'none';
}
if (tab === 'New' || tab === 'Popular') loadFeedTab(tab);
if (tab === 'New' || tab === 'Popular' || tab === 'Zapped') loadFeedTab(tab);
if (tab === 'Mine') loadMyDesigns();
}
@@ -8861,7 +9304,7 @@
await loadMyDesigns();
return;
}
if (tab === 'New' || tab === 'Popular') {
if (tab === 'New' || tab === 'Popular' || tab === 'Zapped') {
await loadFeedTab(tab);
return;
}
@@ -8968,9 +9411,22 @@
if (tab !== 'Mine') rows = dedupByFingerprint(rows);
if (tab === 'New') {
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
} else if (tab === 'Zapped') {
rows = deduplicateRows(rows);
// Pure zap-total sort: zapped designs first, ties broken by date.
// Unzapped designs sink to the bottom but stay in the list, so the
// tab is still useful before zap data has streamed in.
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b)))
|| (zapTotalForEvent(b.e?.id || '') - zapTotalForEvent(a.e?.id || ''))
|| (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust])
|| (b.createdAt - a.createdAt));
} else {
rows = deduplicateRows(rows);
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (b.reactions - a.reactions) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b)))
|| (zapTotalForEvent(b.e?.id || '') - zapTotalForEvent(a.e?.id || ''))
|| (b.reactions - a.reactions)
|| (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust])
|| (b.createdAt - a.createdAt));
}
updateFeedStatus(tab, rows.length);
if (!rows.length) {
@@ -9241,6 +9697,15 @@
});
metaEl.appendChild(authorLink);
}
const previewZapMsats = zapTotalForEvent(row.e?.id || '');
if (previewZapMsats > 0) {
metaEl.appendChild(document.createTextNode(' · '));
const zapBadge = document.createElement('span');
zapBadge.className = 'feedCardZapBadge';
zapBadge.textContent = `⚡ ${formatMsatsAsSats(previewZapMsats)}`;
zapBadge.title = `${Math.floor(previewZapMsats / 1000).toLocaleString()} sats zapped to this design`;
metaEl.appendChild(zapBadge);
}
const content = $('nostrPreviewPanel');
content.innerHTML = '';
content._row = row;
@@ -9264,12 +9729,23 @@
if (previewLnurlpUrl) {
const previewLnAddress = (previewAuthorProfile?.lud16 || '').trim();
const previewName = previewAuthorProfile?.display_name || previewAuthorProfile?.name || shortPubkey(previewPk);
const previewDTag = (row.e?.tags || []).find(t => t[0] === 'd' && t[1])?.[1] || '';
const tipBtn = document.createElement('button');
tipBtn.type = 'button';
tipBtn.textContent = '⚡ Tip';
tipBtn.title = previewLnAddress ? `Send a Lightning tip to ${previewLnAddress}` : 'Send a Lightning tip';
tipBtn.addEventListener('click', () => {
openTipModal({ lnurlpUrl: previewLnurlpUrl, label: previewName, lud16: previewLnAddress });
openTipModal({
lnurlpUrl: previewLnurlpUrl,
label: previewName,
lud16: previewLnAddress,
targetEvent: {
authorPubkey: previewPk,
eventId: row.e?.id || '',
kind: row.e?.kind || 30078,
dTag: previewDTag
}
});
});
actionsDiv.insertBefore(tipBtn, saveJsonBtn);
}
@@ -9375,7 +9851,7 @@
$('nostrPreviewActions').style.display = 'flex';
$('nostrBackBtn').style.display = '';
$('nostrModal').querySelector('.modalTabs').style.display = 'none';
for (const t of ['New', 'Popular', 'Search', 'Mine']) {
for (const t of ['New', 'Popular', 'Zapped', 'Search', 'Mine']) {
const el = $(`panel${t}`);
if (el) el.style.display = 'none';
}
@@ -9405,7 +9881,7 @@
$('nostrPreviewPanel').style.display = 'none';
const rd = $('nostrRelayDetails');
if (rd) rd.style.display = '';
for (const t of ['New', 'Popular', 'Search', 'Mine']) {
for (const t of ['New', 'Popular', 'Zapped', 'Search', 'Mine']) {
const tabEl = $(`tab${t}`);
const panelEl = $(`panel${t}`);
if (panelEl) panelEl.style.display = tabEl?.classList.contains('active') ? '' : 'none';
@@ -10393,7 +10869,7 @@
async function loadWelcomeTab(tab) {
_welcomeTab = tab;
['New', 'Popular', 'Search'].forEach(t => {
['New', 'Popular', 'Zapped', 'Search'].forEach(t => {
const btn = $(`welcomeTab${t}`);
if (btn) btn.classList.toggle('active', t === tab);
});
@@ -10409,7 +10885,17 @@
rows = dedupByFingerprint(rows);
if (tab === 'Popular') {
rows = deduplicateRows(rows);
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (b.reactions - a.reactions) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b)))
|| (zapTotalForEvent(b.e?.id || '') - zapTotalForEvent(a.e?.id || ''))
|| (b.reactions - a.reactions)
|| (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust])
|| (b.createdAt - a.createdAt));
} else if (tab === 'Zapped') {
rows = deduplicateRows(rows);
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b)))
|| (zapTotalForEvent(b.e?.id || '') - zapTotalForEvent(a.e?.id || ''))
|| (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust])
|| (b.createdAt - a.createdAt));
} else {
rows.sort((a, b) => (Number(isPreviewBroken(a)) - Number(isPreviewBroken(b))) || (TIER_WEIGHT[a.trust] - TIER_WEIGHT[b.trust]) || (b.createdAt - a.createdAt));
}
@@ -10448,7 +10934,12 @@
function openWelcomeBrowseAll() {
dismissWelcomeModal();
$('nostrModal').classList.add('open');
if (typeof switchModalTab === 'function') switchModalTab(_welcomeTab === 'Popular' ? 'Popular' : 'New');
if (typeof switchModalTab === 'function') {
const mapped = _welcomeTab === 'Popular' ? 'Popular'
: _welcomeTab === 'Zapped' ? 'Zapped'
: 'New';
switchModalTab(mapped);
}
}
function attachEvents() {
@@ -10599,6 +11090,7 @@
$("nostrRunSearch").addEventListener("click", runNostrSearch);
$("tabNew").addEventListener("click", () => switchModalTab('New'));
$("tabPopular").addEventListener("click", () => switchModalTab('Popular'));
$("tabZapped").addEventListener("click", () => switchModalTab('Zapped'));
$("tabSearch").addEventListener("click", () => switchModalTab('Search'));
$("tabMine").addEventListener("click", () => switchModalTab('Mine'));
$("nostrLoginBtn").addEventListener("click", nostrLogin);
@@ -10639,7 +11131,7 @@
});
});
['New', 'Popular'].forEach(tab => {
['New', 'Popular', 'Zapped'].forEach(tab => {
const sel = $(`trustFilter${tab}`);
if (sel) sel.addEventListener('change', () => {
_feedState[`trustFilter${tab}`] = sel.value;
@@ -10919,6 +11411,7 @@
$('welcomeBrowseAllBtn').addEventListener('click', openWelcomeBrowseAll);
$('welcomeTabNew').addEventListener('click', () => loadWelcomeTab('New'));
$('welcomeTabPopular').addEventListener('click', () => loadWelcomeTab('Popular'));
$('welcomeTabZapped').addEventListener('click', () => loadWelcomeTab('Zapped'));
$('welcomeQuery').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); runWelcomeSearch(); } });
$('welcomeType').addEventListener('change', () => { if (_welcomeTab !== 'Search') loadWelcomeTab(_welcomeTab); });
$('welcomeCategory').addEventListener('change', () => { if (_welcomeTab !== 'Search') loadWelcomeTab(_welcomeTab); });