Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0267233f87 | ||
|
|
6b45092bc8 | ||
|
|
6d39188967 | ||
|
|
c5f682c5fe | ||
|
|
073fb9e9af | ||
|
|
2f45c78741 | ||
|
|
6d41680297 | ||
|
|
b2a50376df | ||
|
|
3c4be89ead | ||
|
|
89d2c259f6 | ||
|
|
7b77da7349 | ||
|
|
38661b7fb6 | ||
|
|
eed00f67cf |
@@ -51,7 +51,7 @@ Routes are parsed by splitting the hash on `/`:
|
||||
```
|
||||
#/playlist/abc123/ep-42 → { page: 'playlist', parts: ['abc123', 'ep-42'] }
|
||||
#/search/bob marley → { page: 'search', parts: ['bob marley'] }
|
||||
#/track/98765 → { page: 'track', parts: ['98765'] }
|
||||
#/track/36787:pubkey:dTag → { page: 'track', parts: ['36787:pubkey:dTag'] }
|
||||
```
|
||||
|
||||
## URL Examples
|
||||
@@ -73,8 +73,8 @@ vj.html?npub=npub1abc123...&show=saturday-reggae&episode=ep-1744382400
|
||||
|
||||
### Direct track link (works on either page)
|
||||
```
|
||||
music.html#/track/12345678
|
||||
vj.html#/track/12345678
|
||||
music.html#/track/36787:aaaaaaaa...:my-track-dtag
|
||||
vj.html#/track/36787:aaaaaaaa...:my-track-dtag
|
||||
```
|
||||
|
||||
### Search link
|
||||
@@ -95,7 +95,7 @@ music.html#/album/album-id-here
|
||||
| Select/create episode | `?episode={id}` added/updated | `replaceState` |
|
||||
| Search for music | `#/search/{query}` | hash assignment |
|
||||
| Click album/artist | `#/album/{id}` or `#/artist/{id}` | hash assignment |
|
||||
| Play track from link | `#/track/{id}` | hash assignment |
|
||||
| Play track from link | `#/track/{id}` (`id` is now typically `36787:pubkey:dTag`) | hash assignment |
|
||||
| Select playlist | `#/playlist/{id}` | hash assignment |
|
||||
| Login / auth change | `?npub={npub}` added | `replaceState` |
|
||||
| Load external user | `?npub={npub}` or `?pubkey={hex}` | page navigation |
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
The music page ([`www/music.html`](www/music.html:1)) uses a **single unified queue** (`musicQueue[]`) as the source of truth for all playback. The audio engine ([`SimplePlayer`](www/js/greyscale-player.mjs:8)) is a pure playback component — it plays whatever stream URL it receives and fires callbacks when tracks end.
|
||||
|
||||
As of the Gruuv migration, track discovery/playback is relay-native:
|
||||
- Track metadata is read from Nostr events (`kind 36787`, `t=gruuv`) via [`GruuvAPI`](www/js/gruuv-api.mjs:333)
|
||||
- Playlist events are published/read as `kind 34139` via [`gruuv-playlists.mjs`](www/js/gruuv-playlists.mjs:1)
|
||||
- Track IDs in queue/URLs are now addressable coordinates (`36787:pubkey:dTag`), not numeric Tidal IDs
|
||||
- Uploads use Blossom + `kind 24242` auth and publish `kind 36787` via [`uploadGruuvTrack()`](www/js/gruuv-upload.mjs:139)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -72,7 +78,7 @@ Persisted to localStorage under key `music-queue:{pubkey}`:
|
||||
{
|
||||
"currentIndex": 2,
|
||||
"items": [
|
||||
{ "id": 123, "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
|
||||
{ "id": "36787:<pubkey>:<dTag>", "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
368
plans/feed-upgrade.md
Normal file
368
plans/feed-upgrade.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# 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
|
||||
|
||||
- [ ] **1.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts.
|
||||
|
||||
- [ ] **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')`.
|
||||
|
||||
- [ ] **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)
|
||||
|
||||
- [ ] **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
|
||||
|
||||
- [ ] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav, footer.
|
||||
|
||||
- [ ] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed.
|
||||
|
||||
- [ ] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`.
|
||||
|
||||
- [ ] **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] }`.
|
||||
|
||||
- [ ] **2.5** Render the comment thread below the original post:
|
||||
- Flat thread (all replies in chronological order) — simplest, most reliable
|
||||
- Each reply rendered using `renderPostItem` from `post-interactions.mjs`
|
||||
- Start with flat; can add nesting/threading later
|
||||
|
||||
- [ ] **2.6** Add an inline composer at the bottom for posting replies. The composer should:
|
||||
- Tag the original post: `['e', postId, '', 'reply']`
|
||||
- Tag the original author: `['p', postPubkey]`
|
||||
- Use the existing post composer component (`post-composer.mjs`)
|
||||
|
||||
- [ ] **2.7** Real-time updates — subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive.
|
||||
|
||||
- [ ] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions.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
|
||||
|
||||
- [ ] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html` (or a shared module):
|
||||
- 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>`
|
||||
|
||||
- [ ] **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
|
||||
|
||||
- [ ] **2.5.3** Render each reply with vertical indent lines:
|
||||
- Wrap each reply in a container div with `padding-left: calc(level * 12px)` (or similar)
|
||||
- For each level 0..N-1, add a vertical line using `border-left: 2px solid var(--muted-color)` on a positioned div
|
||||
- The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)`
|
||||
- Use CSS pseudo-elements or nested divs for the lines
|
||||
|
||||
- [ ] **2.5.4** Add collapse/expand functionality:
|
||||
- Each reply has a collapse toggle (small button or click on the vertical line)
|
||||
- Collapsing a reply hides all of its descendants
|
||||
- Show a count of hidden replies when collapsed (e.g., "3 replies hidden")
|
||||
- Collapsed state stored in a Set in memory
|
||||
|
||||
- [ ] **2.5.5** Highlight the focused post:
|
||||
- If the URL has a `#<eventId>` hash or a `focus=<eventId>` param, scroll to and highlight that post
|
||||
- The highlighted post's vertical line uses `var(--accent-color)`
|
||||
|
||||
### 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.
|
||||
187
plans/music-greyscale-to-gruuv-migration.md
Normal file
187
plans/music-greyscale-to-gruuv-migration.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Music: Greyscale → Gruuv API Migration Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Fully replace the Greyscale (Tidal-proxy) music backend used by [`www/music.html`](../www/music.html) with the **Gruuv** Nostr-native protocol. This includes search, browse, streaming, playlists, and a new track upload capability. Greyscale code is removed once the gruuv path is verified.
|
||||
|
||||
A backup of the original page is preserved as [`www/music-greyscale.html`](../www/music-greyscale.html).
|
||||
|
||||
---
|
||||
|
||||
## Background: Why this is an architecture shift, not a swap
|
||||
|
||||
The two backends are fundamentally different in nature.
|
||||
|
||||
| Aspect | Greyscale (current) | Gruuv (target) |
|
||||
|---|---|---|
|
||||
| Catalog source | Tidal proxy REST API over HTTP instance pool | Nostr relays: `kind 36787` track events tagged `t=gruuv` |
|
||||
| Search | Server endpoint `/search/?s=` | Relay query + client-side text filter on title/artist/album |
|
||||
| Streaming | DASH/MP3 manifest decoded from instance | Direct Blossom blob URL (the `url` tag) |
|
||||
| Track ID | Numeric Tidal ID | Addressable coordinate `36787:pubkey:dTag` |
|
||||
| Album | Rich `/album/?id=` endpoint | Derived by grouping track events on the `album` tag |
|
||||
| Artist | Rich `/artist/?id=` endpoint | Derived by grouping track events on the `artist` tag |
|
||||
| Cover art | `resources.tidal.com/images/...` | The `image` tag on the track event |
|
||||
| Playlists | `kind 30004`, `i` tags `tidal:track:ID` | `kind 34139`, `a` tags `36787:pubkey:dTag`, `t=gruuv` |
|
||||
| Upload | N/A (read-only Tidal) | Blossom `PUT /upload` + publish `kind 36787` |
|
||||
|
||||
### What already exists in the project (reused, not rebuilt)
|
||||
|
||||
- [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs) — provides `subscribe()`, `publishEvent()`, `publishRawEvent()`, `getPubkey()`. The page already wires NDK, relay status UI, and the `ndkEvent` window-event stream.
|
||||
- [`www/js/blossom-api.mjs`](../www/js/blossom-api.mjs) — provides `calculateSHA256()` and `createBlossomAuth()`, which already builds the **`kind 24242`** authorization event that Gruuv's upload spec requires (see [`gruuv/README.md`](../gruuv/README.md)).
|
||||
- [`www/js/blossom-ui.mjs`](../www/js/blossom-ui.mjs) — provides `getBlossomServers()` for the active Blossom target.
|
||||
- [`SimplePlayer`](../www/js/greyscale-player.mjs) — its direct-URL playback path already handles plain audio URLs (Blossom URLs are plain `https`), so playback needs no DASH logic for gruuv.
|
||||
|
||||
### Gruuv protocol reference (from `gruuv/`)
|
||||
|
||||
`kind 36787` track event tags (per [`gruuv/upload_gruuv.sh`](../gruuv/upload_gruuv.sh) and [`gruuv/README.md`](../gruuv/README.md)):
|
||||
|
||||
```
|
||||
d stable track id / dTag
|
||||
url blossom file url
|
||||
title song title
|
||||
artist artist name
|
||||
album album name
|
||||
duration seconds (string)
|
||||
size bytes (string)
|
||||
alt "Song: <title> - <artist>"
|
||||
format mp3 | flac | ...
|
||||
m audio mime (audio/mpeg, audio/flac, ...)
|
||||
x sha256 hex
|
||||
t music
|
||||
t gruuv
|
||||
image (optional) cover art url
|
||||
genre (optional) + t=<genre-lowercase>
|
||||
released (optional) date-like
|
||||
track_number (optional) plain numeric string
|
||||
disc_number (optional, omit when disc 1)
|
||||
```
|
||||
|
||||
`kind 34139` playlist event tags:
|
||||
|
||||
```
|
||||
d playlist id / dTag
|
||||
title
|
||||
description
|
||||
alt "Playlist: <title>"
|
||||
t gruuv
|
||||
image (optional)
|
||||
a 36787:<pubkey>:<dTag> (repeated, one per track)
|
||||
```
|
||||
|
||||
Default relays observed: `wss://relay.primal.net`, `wss://nos.lol`, `wss://relay.damus.io`. Default Blossom: `https://blossom.primal.net`. (The page's own NDK relay config / Blossom server selection should be used in practice.)
|
||||
|
||||
---
|
||||
|
||||
## Design strategy: drop-in module replacement
|
||||
|
||||
To keep [`www/music.html`](../www/music.html) changes minimal and low-risk, the new gruuv modules will expose the **same method names and return shapes** as the current greyscale modules. Then the migration in `music.html` is mostly an import swap plus targeted fixes for the few places where the data model genuinely differs (IDs, derived albums/artists, upload UI).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph page [music.html UI - mostly unchanged]
|
||||
Search[Search box]
|
||||
Browse[Album / Artist views]
|
||||
Player[SimplePlayer audio]
|
||||
Playlists[Playlist panel]
|
||||
Upload[NEW Upload panel]
|
||||
end
|
||||
|
||||
subgraph mods [New gruuv modules]
|
||||
API[gruuv-api.mjs]
|
||||
PL[gruuv-playlists.mjs]
|
||||
UP[gruuv-upload.mjs]
|
||||
end
|
||||
|
||||
subgraph infra [Existing project infra]
|
||||
NDK[init-ndk.mjs subscribe publishEvent]
|
||||
BLOB[blossom-api.mjs sha256 kind 24242 auth]
|
||||
BUI[blossom-ui.mjs getBlossomServers]
|
||||
end
|
||||
|
||||
Search --> API
|
||||
Browse --> API
|
||||
Player --> API
|
||||
Playlists --> PL
|
||||
Upload --> UP
|
||||
API --> NDK
|
||||
PL --> NDK
|
||||
UP --> BLOB
|
||||
UP --> BUI
|
||||
UP --> NDK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data model mapping (gruuv track event → music.html track shape)
|
||||
|
||||
The UI expects the normalized shape produced by [`GreyscaleAPI.prepareTrack()`](../www/js/greyscale-api.mjs:186):
|
||||
`{ id, title, artist, duration, cover, albumTitle, raw }`.
|
||||
|
||||
Mapping from a `kind 36787` event:
|
||||
|
||||
| UI field | Source |
|
||||
|---|---|
|
||||
| `id` | addressable coordinate `36787:<pubkey>:<d-tag>` (fallback to event id) |
|
||||
| `title` | `title` tag |
|
||||
| `artist` | `artist` tag (fallback "Unknown artist") |
|
||||
| `duration` | `duration` tag parsed to number |
|
||||
| `cover` | `image` tag (no Tidal URL transform) |
|
||||
| `albumTitle` | `album` tag |
|
||||
| `streamUrl` | `url` tag (used by `getTrackStream`) |
|
||||
| `raw` | full event for later reference |
|
||||
|
||||
`getCoverUrl()` / `getArtistPictureUrl()` become pass-through helpers (return the URL as-is) so existing render code keeps working.
|
||||
|
||||
---
|
||||
|
||||
## Implementation steps
|
||||
|
||||
### Phase 0 — Safety
|
||||
1. Confirm [`www/music-greyscale.html`](../www/music-greyscale.html) is a faithful backup of the original greyscale page so it can be referenced/restored.
|
||||
|
||||
### Phase 1 — Read path (search, browse, stream)
|
||||
2. Create [`www/js/gruuv-api.mjs`](../www/js/gruuv-api.mjs) exposing the same surface as `GreyscaleAPI`:
|
||||
`searchTracks`, `searchAlbums`, `searchArtists`, `getAlbum`, `getArtist`, `getTrackStream`, `getCoverUrl`, `getArtistPictureUrl`, `prepareTrack`.
|
||||
3. Implement search by subscribing to `kind 36787` with `#t: ['gruuv']`, collecting events until EOSE (with a timeout), then client-side filtering on title/artist/album. Provide a small in-memory cache of fetched track events to back album/artist derivation.
|
||||
4. Implement `getAlbum` / `getArtist` by grouping the cached/fetched track events on the `album` and `artist` tags respectively (gruuv has no album/artist DB). `getAlbum(id)` returns `{ album, tracks }`; `getArtist(id)` returns `{ ...artist, albums, eps, tracks }` to match existing render expectations.
|
||||
5. Implement `getTrackStream` to return `{ streamUrl: <url tag>, isDash: false }`. Cover/artwork helpers return the `image` URL directly.
|
||||
|
||||
### Phase 2 — Playlists
|
||||
6. Create [`www/js/gruuv-playlists.mjs`](../www/js/gruuv-playlists.mjs) mirroring the exports of [`greyscale-playlists.mjs`](../www/js/greyscale-playlists.mjs):
|
||||
`PLAYLIST_KIND` (now `34139`), `buildPlaylistEvent`, `parsePlaylistEvent`, `isMusicPlaylistEvent`, `createPlaylistIdentifier`, `playlistEventAddress`, and any helper the page imports.
|
||||
- Track references use `a` tags of form `36787:<pubkey>:<dTag>` instead of `i`/`tidal:track:` tags.
|
||||
- Topic tag becomes `t=gruuv` (keep `t=music` for discoverability if useful).
|
||||
- Keep a content JSON cache of track metadata (title/artist/cover/duration) like the greyscale version so playlists render before track events resolve.
|
||||
|
||||
### Phase 3 — Upload (new capability)
|
||||
7. Create [`www/js/gruuv-upload.mjs`](../www/js/gruuv-upload.mjs):
|
||||
- `calculateSHA256()` (reuse from blossom-api).
|
||||
- Build `kind 24242` auth via `createBlossomAuth('upload', sha256, 'Upload <filename>')`.
|
||||
- `PUT <activeBlossomServer>/upload` with the `Authorization: Nostr <base64>` header; fall back to `<server>/<sha256>` for the URL if the response lacks one.
|
||||
- Optionally read duration via an `<audio>`/`AudioContext` decode client-side; allow manual title/artist/album entry.
|
||||
- Publish `kind 36787` via `publishEvent()` with the full tag set (including `t=music`, `t=gruuv`, `x`, `m`, `format`, `size`, `alt`).
|
||||
|
||||
### Phase 4 — Wire into music.html
|
||||
8. Swap imports: replace [`GreyscaleAPI`](../www/music.html:1371), the greyscale playlists import block ([lines ~1375–1380](../www/music.html:1375)) with gruuv modules. Keep `SimplePlayer` (it works with direct URLs); the DASH branch simply goes unused.
|
||||
9. Update search handlers ([~3117](../www/music.html:3117), combined search [~4516](../www/music.html:4516)) and detail loaders ([~3837](../www/music.html:3837)–[~3963](../www/music.html:3963)) for the gruuv id format and the grouped album/artist results.
|
||||
10. Update playlist subscription filter and event handling ([`subscribeAllPlaylists`](../www/music.html:3288), [`handleMusicNdkEvent`](../www/music.html:3299), [`hydratePlaylistFromEvent`](../www/music.html:3245)) to `kind 34139` with `t=gruuv` and `a`-tag track refs.
|
||||
11. Add an **Upload Track** UI section (file input, metadata fields, progress/status), gated behind authentication (`promptLoginIfNeeded`), wired to `gruuv-upload.mjs`.
|
||||
12. Update the download ([`fetchDownloadBlobForTrack`](../www/music.html:4342)) and share flows to use the direct Blossom URL instead of Tidal stream manifests.
|
||||
|
||||
### Phase 5 — Cleanup & verification
|
||||
13. Remove greyscale modules ([`greyscale-api.mjs`](../www/js/greyscale-api.mjs), [`greyscale-playlists.mjs`](../www/js/greyscale-playlists.mjs), and [`greyscale-player.mjs`](../www/js/greyscale-player.mjs) if no longer referenced) and any greyscale-only code paths — only after the gruuv path is verified. (Player may be retained/renamed if still used for playback.)
|
||||
14. Run the build inline-check ([`build/.music-inline-check.mjs`](../build/.music-inline-check.mjs)) and do a relay smoke test (cross-check against [`gruuv/list_gruuv_tracks.sh`](../gruuv/list_gruuv_tracks.sh) output). Verify an upload round-trips: publish `36787`, then it appears in search.
|
||||
15. Update docs: [`docs/music.md`](../docs/music.md) and [`docs/MUSIC_URL_STRUCTURE.md`](../docs/MUSIC_URL_STRUCTURE.md) to reflect gruuv kinds/IDs and the new upload feature.
|
||||
|
||||
---
|
||||
|
||||
## Risks & decisions
|
||||
|
||||
- **Relay-native search has no ranking/pagination like a server endpoint.** Mitigation: fetch with a sensible `limit`, cache events, filter client-side, debounce queries. Result completeness depends on relay coverage.
|
||||
- **Albums/artists are derived, not authoritative.** Grouping by free-text `album`/`artist` tags can produce duplicates/variants. Mitigation: normalize/trim, dedupe like the existing `deduplicateAlbums`.
|
||||
- **Track addressing change** (`36787:pubkey:dTag` vs numeric ID) affects deep links and URL structure — covered by the `MUSIC_URL_STRUCTURE.md` update.
|
||||
- **Upload duration/metadata** extraction is best-effort in the browser (no ffprobe); manual fields are the reliable fallback.
|
||||
- **Default relay/Blossom set**: plan uses the page's existing NDK relays and `getBlossomServers()`; gruuv defaults are documented as fallback. Confirm if a specific relay/Blossom set is required.
|
||||
|
||||
## Open question for confirmation
|
||||
- Should the deep-link/URL scheme keep numeric-style IDs (with a translation layer) or move fully to `36787:pubkey:dTag` coordinates?
|
||||
178
plans/wallet-page.md
Normal file
178
plans/wallet-page.md
Normal 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.19–v0.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.
|
||||
@@ -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);
|
||||
|
||||
@@ -342,6 +342,7 @@
|
||||
<div class="panelTitle">Kinds</div>
|
||||
<div class="btnRow">
|
||||
<button id="btnRefreshKinds" class="btnMini" type="button">Refresh Cache</button>
|
||||
<button id="btnGetAllEvents" class="btnMini" type="button">Get all events</button>
|
||||
</div>
|
||||
<div id="divKindsStatus" class="hint">Loading...</div>
|
||||
<div id="divKindsList" class="listWrap"></div>
|
||||
@@ -683,12 +684,16 @@
|
||||
let selectedKindSub = null;
|
||||
let currentDetailEvent = null;
|
||||
let lastDecryptResult = null;
|
||||
let allEventsFetchSub = null;
|
||||
let allEventsFetchInFlight = false;
|
||||
let allEventsFetchTimeoutId = null;
|
||||
|
||||
const eventsByKind = new Map(); // kind -> Map(eventKey, event)
|
||||
const selectedEventKeys = new Set();
|
||||
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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
@@ -703,6 +708,7 @@
|
||||
const divFooterRight = document.getElementById('divFooterRight');
|
||||
|
||||
const btnRefreshKinds = document.getElementById('btnRefreshKinds');
|
||||
const btnGetAllEvents = document.getElementById('btnGetAllEvents');
|
||||
const divKindsStatus = document.getElementById('divKindsStatus');
|
||||
const divKindsList = document.getElementById('divKindsList');
|
||||
|
||||
@@ -1022,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);
|
||||
@@ -1396,16 +1416,22 @@
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedKindsAndEventTotals() {
|
||||
const kinds = getKindsSorted();
|
||||
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
|
||||
return { kindsCount: kinds.length, totalEvents };
|
||||
}
|
||||
|
||||
function renderKinds() {
|
||||
const kinds = getKindsSorted();
|
||||
if (!kinds.length) {
|
||||
divKindsList.innerHTML = '<div class="hint">No cached events found.</div>';
|
||||
divKindsStatus.textContent = '0 kinds · 0 events';
|
||||
if (!allEventsFetchInFlight) divKindsStatus.textContent = '0 kinds · 0 events';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
|
||||
divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
if (!allEventsFetchInFlight) divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
|
||||
divKindsList.innerHTML = kinds.map((kind) => {
|
||||
const count = getEventsForKind(kind).length;
|
||||
@@ -1682,6 +1708,63 @@
|
||||
selectedKindSub = subscribe({ kinds: [Number(kind)], authors: [currentPubkey], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||||
}
|
||||
|
||||
function clearAllEventsFetchTimeout() {
|
||||
if (allEventsFetchTimeoutId) {
|
||||
clearTimeout(allEventsFetchTimeoutId);
|
||||
allEventsFetchTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllEventsFetchSubscription() {
|
||||
try {
|
||||
if (allEventsFetchSub && typeof allEventsFetchSub.close === 'function') allEventsFetchSub.close();
|
||||
} catch (_error) { }
|
||||
allEventsFetchSub = null;
|
||||
}
|
||||
|
||||
function finalizeGetAllEventsFetch(statusText) {
|
||||
clearAllEventsFetchTimeout();
|
||||
closeAllEventsFetchSubscription();
|
||||
allEventsFetchInFlight = false;
|
||||
if (btnGetAllEvents) btnGetAllEvents.disabled = false;
|
||||
if (statusText) {
|
||||
divKindsStatus.textContent = statusText;
|
||||
} else {
|
||||
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
|
||||
divKindsStatus.textContent = `${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runOneShotGetAllEventsFetch() {
|
||||
if (allEventsFetchInFlight) return;
|
||||
if (!currentPubkey) {
|
||||
divKindsStatus.textContent = 'Get all events failed: user pubkey unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
allEventsFetchInFlight = true;
|
||||
if (btnGetAllEvents) btnGetAllEvents.disabled = true;
|
||||
|
||||
const beforeTotals = getCachedKindsAndEventTotals();
|
||||
divKindsStatus.textContent = `Getting all events from relays for ${shortHex(currentPubkey)}...`;
|
||||
|
||||
try {
|
||||
closeAllEventsFetchSubscription();
|
||||
allEventsFetchSub = subscribe(
|
||||
{ authors: [currentPubkey] },
|
||||
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
|
||||
);
|
||||
|
||||
allEventsFetchTimeoutId = setTimeout(() => {
|
||||
const afterTotals = getCachedKindsAndEventTotals();
|
||||
const added = Math.max(0, afterTotals.totalEvents - beforeTotals.totalEvents);
|
||||
finalizeGetAllEventsFetch(`Get all events timed out · +${added.toLocaleString()} events (${afterTotals.totalEvents.toLocaleString()} total)`);
|
||||
}, 30000);
|
||||
} catch (error) {
|
||||
finalizeGetAllEventsFetch(`Get all events failed: ${error?.message || String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectKind(kind, options = {}) {
|
||||
const { autoRefresh = true } = options;
|
||||
|
||||
@@ -1747,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`;
|
||||
@@ -1992,6 +2080,8 @@
|
||||
});
|
||||
|
||||
logoutButton?.addEventListener('click', async () => {
|
||||
closeAllEventsFetchSubscription();
|
||||
clearAllEventsFetchTimeout();
|
||||
try { await logout(); } catch (error) { console.error('[event-management.html] logout failed', error); }
|
||||
});
|
||||
|
||||
@@ -2004,6 +2094,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
btnGetAllEvents?.addEventListener('click', async () => {
|
||||
await runOneShotGetAllEventsFetch();
|
||||
});
|
||||
|
||||
inpEventSearch.addEventListener('input', renderEvents);
|
||||
chkShowSuperseded?.addEventListener('change', renderEvents);
|
||||
|
||||
@@ -2319,6 +2413,15 @@
|
||||
renderEvents();
|
||||
});
|
||||
|
||||
window.addEventListener('ndkEose', (event) => {
|
||||
if (!allEventsFetchInFlight || !allEventsFetchSub?.subId) return;
|
||||
const eoseSubId = String(event?.detail?.subId || '');
|
||||
if (!eoseSubId || eoseSubId !== String(allEventsFetchSub.subId)) return;
|
||||
|
||||
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
|
||||
finalizeGetAllEventsFetch(`Get all events complete · ${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`);
|
||||
});
|
||||
|
||||
window.addEventListener('ndkEvent', (event) => {
|
||||
const evt = event?.detail;
|
||||
if (!evt || !Number.isFinite(Number(evt.kind)) || !evt.id) return;
|
||||
|
||||
714
www/feed2.html
Normal file
714
www/feed2.html
Normal file
@@ -0,0 +1,714 @@
|
||||
<!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"></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 }) {
|
||||
if (!postId) return false;
|
||||
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
|
||||
return true;
|
||||
}
|
||||
|
||||
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,
|
||||
queryCache,
|
||||
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);
|
||||
|
||||
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:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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,
|
||||
|
||||
542
www/js/gruuv-api.mjs
Normal file
542
www/js/gruuv-api.mjs
Normal file
@@ -0,0 +1,542 @@
|
||||
import { ndkFetchEvents, queryCache } from './init-ndk.mjs';
|
||||
|
||||
const TRACK_KIND = 36787;
|
||||
const GRUUV_TOPIC = 'gruuv';
|
||||
const MUSIC_TOPIC = 'music';
|
||||
const DEFAULT_LIMIT = 600;
|
||||
|
||||
function normalizeText(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeLower(value = '') {
|
||||
return normalizeText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function safeNumber(value, fallback = 0) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function getTagValues(tags, key) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags
|
||||
.filter((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string')
|
||||
.map((t) => t[1]);
|
||||
}
|
||||
|
||||
function getTagValue(tags, key) {
|
||||
const values = getTagValues(tags, key);
|
||||
return values[0] || '';
|
||||
}
|
||||
|
||||
function normalizeSlug(value = '') {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 96);
|
||||
}
|
||||
|
||||
function parseTrackCoordinate(id = '') {
|
||||
const raw = String(id || '').trim();
|
||||
if (!raw.startsWith(`${TRACK_KIND}:`)) return null;
|
||||
const parts = raw.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
const kind = Number(parts[0]);
|
||||
const pubkey = String(parts[1] || '').trim().toLowerCase();
|
||||
const dTag = parts.slice(2).join(':').trim();
|
||||
if (kind !== TRACK_KIND) return null;
|
||||
if (!/^[a-f0-9]{64}$/i.test(pubkey) || !dTag) return null;
|
||||
return { kind, pubkey, dTag };
|
||||
}
|
||||
|
||||
function isHexEventId(value = '') {
|
||||
return /^[a-f0-9]{64}$/i.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function inferFormatFromMime(mime = '') {
|
||||
const m = String(mime || '').toLowerCase();
|
||||
if (m.includes('flac')) return 'flac';
|
||||
if (m.includes('mpeg') || m.includes('mp3')) return 'mp3';
|
||||
if (m.includes('aac')) return 'aac';
|
||||
if (m.includes('ogg')) return 'ogg';
|
||||
if (m.includes('wav')) return 'wav';
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasTopic(tags, topic) {
|
||||
const lower = normalizeLower(topic);
|
||||
return getTagValues(tags, 't').some((v) => normalizeLower(v) === lower);
|
||||
}
|
||||
|
||||
function buildTrackIdFromEvent(event) {
|
||||
const pubkey = normalizeLower(event?.pubkey || '');
|
||||
const dTag = normalizeText(getTagValue(event?.tags, 'd'));
|
||||
if (pubkey && dTag) return `${TRACK_KIND}:${pubkey}:${dTag}`;
|
||||
return normalizeText(event?.id || '');
|
||||
}
|
||||
|
||||
function parseReleasedYear(released = '') {
|
||||
const match = String(released || '').match(/(\d{4})/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function sortByRecent(a, b) {
|
||||
return safeNumber(b?.created_at, 0) - safeNumber(a?.created_at, 0);
|
||||
}
|
||||
|
||||
function dedupeEvents(events, { requireTopic = true } = {}) {
|
||||
const byAddress = new Map();
|
||||
const byEventId = new Map();
|
||||
|
||||
for (const event of events || []) {
|
||||
if (!event || typeof event !== 'object') continue;
|
||||
if (event.kind !== TRACK_KIND || !Array.isArray(event.tags)) continue;
|
||||
if (requireTopic && !(hasTopic(event.tags, GRUUV_TOPIC) || hasTopic(event.tags, MUSIC_TOPIC))) continue;
|
||||
|
||||
const eventId = normalizeLower(event.id || '');
|
||||
if (eventId && byEventId.has(eventId)) continue;
|
||||
|
||||
const address = buildTrackIdFromEvent(event);
|
||||
const existing = byAddress.get(address);
|
||||
if (!existing || safeNumber(event.created_at, 0) >= safeNumber(existing.created_at, 0)) {
|
||||
byAddress.set(address, event);
|
||||
if (eventId) byEventId.set(eventId, event);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byAddress.values()].sort(sortByRecent);
|
||||
}
|
||||
|
||||
function eventToNormalizedTrack(event) {
|
||||
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
||||
const id = buildTrackIdFromEvent(event);
|
||||
const title = normalizeText(getTagValue(tags, 'title')) || 'Unknown title';
|
||||
const artist = normalizeText(getTagValue(tags, 'artist')) || 'Unknown artist';
|
||||
const albumTitle = normalizeText(getTagValue(tags, 'album'));
|
||||
const duration = safeNumber(getTagValue(tags, 'duration'), 0);
|
||||
const cover = normalizeText(getTagValue(tags, 'image'));
|
||||
const streamUrl = normalizeText(getTagValue(tags, 'url'));
|
||||
const format = normalizeText(getTagValue(tags, 'format')) || inferFormatFromMime(getTagValue(tags, 'm'));
|
||||
const mime = normalizeText(getTagValue(tags, 'm'));
|
||||
const sha256 = normalizeText(getTagValue(tags, 'x'));
|
||||
const released = normalizeText(getTagValue(tags, 'released'));
|
||||
const trackNumber = normalizeText(getTagValue(tags, 'track_number') || getTagValue(tags, 'track'));
|
||||
const discNumber = normalizeText(getTagValue(tags, 'disc_number') || getTagValue(tags, 'disc'));
|
||||
const genre = normalizeText(getTagValue(tags, 'genre'));
|
||||
|
||||
const albumId = albumTitle ? `album:${normalizeSlug(`${artist} ${albumTitle}`)}` : '';
|
||||
const artistId = artist ? `artist:${normalizeSlug(artist)}` : '';
|
||||
|
||||
return {
|
||||
id,
|
||||
eventId: normalizeText(event?.id || ''),
|
||||
pubkey: normalizeText(event?.pubkey || ''),
|
||||
dTag: normalizeText(getTagValue(tags, 'd')),
|
||||
title,
|
||||
artist,
|
||||
artistId,
|
||||
duration,
|
||||
cover,
|
||||
albumTitle,
|
||||
albumId,
|
||||
streamUrl,
|
||||
format,
|
||||
mime,
|
||||
sha256,
|
||||
size: safeNumber(getTagValue(tags, 'size'), 0),
|
||||
released,
|
||||
releaseYear: parseReleasedYear(released),
|
||||
genre,
|
||||
trackNumber,
|
||||
discNumber,
|
||||
createdAt: safeNumber(event?.created_at, 0),
|
||||
raw: event,
|
||||
};
|
||||
}
|
||||
|
||||
function shapeSearchResponse(items) {
|
||||
const rows = Array.isArray(items) ? items : [];
|
||||
return {
|
||||
items: rows,
|
||||
limit: rows.length,
|
||||
offset: 0,
|
||||
totalNumberOfItems: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
function trackMatchesQuery(track, query) {
|
||||
const q = normalizeLower(query);
|
||||
if (!q) return true;
|
||||
|
||||
const haystacks = [
|
||||
track?.title,
|
||||
track?.artist,
|
||||
track?.albumTitle,
|
||||
track?.id,
|
||||
track?.eventId,
|
||||
track?.dTag,
|
||||
track?.genre,
|
||||
]
|
||||
.map((v) => normalizeLower(v))
|
||||
.filter(Boolean);
|
||||
|
||||
return haystacks.some((v) => v.includes(q));
|
||||
}
|
||||
|
||||
function groupTracksByAlbum(tracks) {
|
||||
const byAlbum = new Map();
|
||||
|
||||
for (const track of tracks || []) {
|
||||
const title = normalizeText(track?.albumTitle);
|
||||
if (!title) continue;
|
||||
|
||||
const artist = normalizeText(track?.artist || 'Unknown artist');
|
||||
const key = `${normalizeLower(artist)}::${normalizeLower(title)}`;
|
||||
|
||||
if (!byAlbum.has(key)) {
|
||||
byAlbum.set(key, {
|
||||
id: track.albumId || `album:${normalizeSlug(`${artist} ${title}`)}`,
|
||||
title,
|
||||
cover: normalizeText(track?.cover || ''),
|
||||
artist: { name: artist, id: track.artistId || `artist:${normalizeSlug(artist)}` },
|
||||
artistName: artist,
|
||||
releaseDate: track.released || '',
|
||||
numberOfTracks: 0,
|
||||
explicit: false,
|
||||
type: 'ALBUM',
|
||||
tracks: [],
|
||||
});
|
||||
}
|
||||
|
||||
const album = byAlbum.get(key);
|
||||
album.tracks.push(track);
|
||||
album.numberOfTracks = album.tracks.length;
|
||||
if (!album.cover && track.cover) album.cover = track.cover;
|
||||
if (!album.releaseDate && track.released) album.releaseDate = track.released;
|
||||
}
|
||||
|
||||
return [...byAlbum.values()].sort((a, b) => {
|
||||
const yearDiff = parseReleasedYear(b.releaseDate) - parseReleasedYear(a.releaseDate);
|
||||
if (yearDiff !== 0) return yearDiff;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
function groupTracksByArtist(tracks) {
|
||||
const byArtist = new Map();
|
||||
|
||||
for (const track of tracks || []) {
|
||||
const name = normalizeText(track?.artist);
|
||||
if (!name) continue;
|
||||
|
||||
const key = normalizeLower(name);
|
||||
if (!byArtist.has(key)) {
|
||||
byArtist.set(key, {
|
||||
id: track.artistId || `artist:${normalizeSlug(name)}`,
|
||||
name,
|
||||
picture: normalizeText(track?.cover || ''),
|
||||
tracks: [],
|
||||
});
|
||||
}
|
||||
|
||||
const artist = byArtist.get(key);
|
||||
artist.tracks.push(track);
|
||||
if (!artist.picture && track.cover) artist.picture = track.cover;
|
||||
}
|
||||
|
||||
return [...byArtist.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async function safeFetchWithFilter(filters, options = {}) {
|
||||
const [cacheResult, relayResult] = await Promise.allSettled([
|
||||
queryCache(filters),
|
||||
ndkFetchEvents(filters),
|
||||
]);
|
||||
|
||||
const cacheEvents = cacheResult.status === 'fulfilled' && Array.isArray(cacheResult.value)
|
||||
? cacheResult.value
|
||||
: [];
|
||||
const relayEvents = relayResult.status === 'fulfilled' && Array.isArray(relayResult.value)
|
||||
? relayResult.value
|
||||
: [];
|
||||
|
||||
return dedupeEvents([...cacheEvents, ...relayEvents], options);
|
||||
}
|
||||
|
||||
export class GruuvAPI {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
limit: Number.isFinite(options.limit) ? Number(options.limit) : DEFAULT_LIMIT,
|
||||
...options,
|
||||
};
|
||||
this.trackCache = new Map();
|
||||
this.lastTrackSnapshot = [];
|
||||
}
|
||||
|
||||
async initInstances() {
|
||||
return {
|
||||
api: ['nostr:kind36787'],
|
||||
streaming: ['blossom:url-tag'],
|
||||
};
|
||||
}
|
||||
|
||||
cacheTracks(tracks) {
|
||||
const rows = Array.isArray(tracks) ? tracks : [];
|
||||
for (const track of rows) {
|
||||
if (!track?.id) continue;
|
||||
this.trackCache.set(String(track.id), track);
|
||||
if (track.eventId) this.trackCache.set(String(track.eventId), track);
|
||||
}
|
||||
this.lastTrackSnapshot = rows;
|
||||
}
|
||||
|
||||
async fetchTrackEvents(filters = {}) {
|
||||
const mergedFilter = {
|
||||
kinds: [TRACK_KIND],
|
||||
'#t': [GRUUV_TOPIC],
|
||||
limit: this.options.limit,
|
||||
...filters,
|
||||
};
|
||||
|
||||
const events = await safeFetchWithFilter(mergedFilter);
|
||||
const tracks = events.map(eventToNormalizedTrack).filter((track) => track.streamUrl);
|
||||
this.cacheTracks(tracks);
|
||||
return tracks;
|
||||
}
|
||||
|
||||
prepareTrack(track) {
|
||||
if (!track || typeof track !== 'object') return null;
|
||||
|
||||
if (track.raw && Array.isArray(track.raw.tags)) {
|
||||
return eventToNormalizedTrack(track.raw);
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
id: normalizeText(track.id || ''),
|
||||
title: normalizeText(track.title || '') || 'Unknown title',
|
||||
artist: normalizeText(track.artist || '') || 'Unknown artist',
|
||||
duration: safeNumber(track.duration, 0),
|
||||
cover: normalizeText(track.cover || ''),
|
||||
albumTitle: normalizeText(track.albumTitle || ''),
|
||||
raw: track.raw || track,
|
||||
};
|
||||
|
||||
if (!normalized.id && track.raw?.pubkey && Array.isArray(track.raw?.tags)) {
|
||||
normalized.id = buildTrackIdFromEvent(track.raw);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
getCoverUrl(coverId) {
|
||||
return normalizeText(coverId);
|
||||
}
|
||||
|
||||
getArtistPictureUrl(id) {
|
||||
return normalizeText(id);
|
||||
}
|
||||
|
||||
async searchTracks(query) {
|
||||
const q = normalizeText(query);
|
||||
if (!q) return shapeSearchResponse([]);
|
||||
|
||||
const asCoordinate = parseTrackCoordinate(q);
|
||||
if (asCoordinate) {
|
||||
const tracks = await this.fetchTrackEvents({
|
||||
authors: [asCoordinate.pubkey],
|
||||
'#d': [asCoordinate.dTag],
|
||||
limit: 20,
|
||||
});
|
||||
return shapeSearchResponse(tracks.filter((t) => String(t.id) === q));
|
||||
}
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const filtered = tracks.filter((track) => trackMatchesQuery(track, q));
|
||||
return shapeSearchResponse(filtered);
|
||||
}
|
||||
|
||||
async searchAlbums(query) {
|
||||
const q = normalizeText(query);
|
||||
if (!q) return shapeSearchResponse([]);
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const albums = groupTracksByAlbum(tracks).filter((album) => {
|
||||
const haystack = `${album.title} ${album.artistName}`.toLowerCase();
|
||||
return haystack.includes(normalizeLower(q));
|
||||
});
|
||||
|
||||
return shapeSearchResponse(albums);
|
||||
}
|
||||
|
||||
async searchArtists(query) {
|
||||
const q = normalizeText(query);
|
||||
if (!q) return shapeSearchResponse([]);
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const artists = groupTracksByArtist(tracks).filter((artist) => {
|
||||
const haystack = `${artist.name}`.toLowerCase();
|
||||
return haystack.includes(normalizeLower(q));
|
||||
});
|
||||
|
||||
return shapeSearchResponse(artists);
|
||||
}
|
||||
|
||||
async getAlbum(id) {
|
||||
const albumId = normalizeText(id);
|
||||
if (!albumId) throw new Error('Album id is required');
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const albums = groupTracksByAlbum(tracks);
|
||||
const album = albums.find((a) => String(a.id) === albumId);
|
||||
|
||||
if (!album) throw new Error('Album not found');
|
||||
|
||||
const sortedTracks = [...album.tracks].sort((a, b) => {
|
||||
const aDisc = safeNumber(a.discNumber, 1);
|
||||
const bDisc = safeNumber(b.discNumber, 1);
|
||||
if (aDisc !== bDisc) return aDisc - bDisc;
|
||||
const aTrack = safeNumber(a.trackNumber, Number.MAX_SAFE_INTEGER);
|
||||
const bTrack = safeNumber(b.trackNumber, Number.MAX_SAFE_INTEGER);
|
||||
if (aTrack !== bTrack) return aTrack - bTrack;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
return { album: { ...album, tracks: undefined }, tracks: sortedTracks };
|
||||
}
|
||||
|
||||
async getArtist(artistId) {
|
||||
const id = normalizeText(artistId);
|
||||
if (!id) throw new Error('Artist id is required');
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const artists = groupTracksByArtist(tracks);
|
||||
const artist = artists.find((a) => String(a.id) === id);
|
||||
|
||||
if (!artist) throw new Error('Artist not found');
|
||||
|
||||
const artistTracks = [...artist.tracks].sort((a, b) => {
|
||||
const yearDiff = safeNumber(b.releaseYear, 0) - safeNumber(a.releaseYear, 0);
|
||||
if (yearDiff !== 0) return yearDiff;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
const albums = groupTracksByAlbum(artistTracks)
|
||||
.map((album) => ({ ...album, tracks: undefined }))
|
||||
.filter((album) => normalizeLower(album.artistName) === normalizeLower(artist.name));
|
||||
|
||||
return {
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
picture: artist.picture,
|
||||
albums,
|
||||
eps: [],
|
||||
tracks: artistTracks.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
async getTracksByAddresses(addresses = []) {
|
||||
const input = Array.isArray(addresses) ? addresses : [];
|
||||
const normalizedAddresses = [...new Set(
|
||||
input
|
||||
.map((value) => parseTrackCoordinate(value))
|
||||
.filter(Boolean)
|
||||
.map((coord) => `${TRACK_KIND}:${coord.pubkey}:${coord.dTag}`)
|
||||
)];
|
||||
|
||||
console.log('[gruuv-api] getTracksByAddresses:start', {
|
||||
requested: input.length,
|
||||
normalized: normalizedAddresses.length,
|
||||
normalizedAddresses,
|
||||
});
|
||||
|
||||
const byAddress = new Map();
|
||||
const unresolvedByAuthor = new Map();
|
||||
|
||||
for (const address of normalizedAddresses) {
|
||||
const cached = this.trackCache.get(address);
|
||||
if (cached?.streamUrl) {
|
||||
byAddress.set(address, cached);
|
||||
continue;
|
||||
}
|
||||
const coord = parseTrackCoordinate(address);
|
||||
if (!coord) continue;
|
||||
const set = unresolvedByAuthor.get(coord.pubkey) || new Set();
|
||||
set.add(coord.dTag);
|
||||
unresolvedByAuthor.set(coord.pubkey, set);
|
||||
}
|
||||
|
||||
console.log('[gruuv-api] getTracksByAddresses:cache', {
|
||||
cacheHits: byAddress.size,
|
||||
unresolvedAuthors: unresolvedByAuthor.size,
|
||||
});
|
||||
|
||||
for (const [author, dTagsSet] of unresolvedByAuthor.entries()) {
|
||||
const dTags = [...dTagsSet].filter(Boolean);
|
||||
if (!dTags.length) continue;
|
||||
|
||||
const filter = {
|
||||
kinds: [TRACK_KIND],
|
||||
authors: [author],
|
||||
'#d': dTags,
|
||||
limit: Math.max(120, dTags.length * 3),
|
||||
};
|
||||
|
||||
console.log('[gruuv-api] getTracksByAddresses:fetch', {
|
||||
author,
|
||||
dTags,
|
||||
filter,
|
||||
});
|
||||
|
||||
const events = await safeFetchWithFilter(filter, { requireTopic: false });
|
||||
console.log('[gruuv-api] getTracksByAddresses:events', {
|
||||
author,
|
||||
dTagsRequested: dTags.length,
|
||||
eventsFetched: events.length,
|
||||
eventIds: events.map((event) => event?.id).filter(Boolean),
|
||||
});
|
||||
|
||||
const tracks = events
|
||||
.map(eventToNormalizedTrack)
|
||||
.filter((track) => track.streamUrl);
|
||||
|
||||
this.cacheTracks(tracks);
|
||||
for (const track of tracks) {
|
||||
if (!track?.id) continue;
|
||||
byAddress.set(String(track.id), track);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[gruuv-api] getTracksByAddresses:done', {
|
||||
resolved: byAddress.size,
|
||||
resolvedIds: [...byAddress.keys()],
|
||||
});
|
||||
|
||||
return byAddress;
|
||||
}
|
||||
|
||||
async getTrackStream(id) {
|
||||
const target = normalizeText(id);
|
||||
if (!target) throw new Error('Track id is required');
|
||||
|
||||
let track = this.trackCache.get(target) || null;
|
||||
|
||||
if (!track && parseTrackCoordinate(target)) {
|
||||
const lookup = await this.searchTracks(target);
|
||||
const items = Array.isArray(lookup?.items) ? lookup.items : [];
|
||||
track = items.find((item) => String(item?.id) === target) || items[0] || null;
|
||||
}
|
||||
|
||||
if (!track && isHexEventId(target)) {
|
||||
const tracks = await this.fetchTrackEvents({ ids: [target], limit: 20 });
|
||||
track = tracks.find((item) => String(item.eventId) === target) || null;
|
||||
}
|
||||
|
||||
if (!track) throw new Error('Track not found');
|
||||
if (!track.streamUrl) throw new Error('Track has no stream URL');
|
||||
|
||||
return { streamUrl: track.streamUrl, isDash: false };
|
||||
}
|
||||
}
|
||||
199
www/js/gruuv-playlists.mjs
Normal file
199
www/js/gruuv-playlists.mjs
Normal file
@@ -0,0 +1,199 @@
|
||||
const PLAYLIST_KIND = 34139;
|
||||
const TRACK_KIND = 36787;
|
||||
|
||||
function getTagValue(tags, key) {
|
||||
if (!Array.isArray(tags)) return '';
|
||||
const tag = tags.find((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string');
|
||||
return tag?.[1] || '';
|
||||
}
|
||||
|
||||
function getTagValues(tags, key) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags
|
||||
.filter((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string')
|
||||
.map((t) => t[1]);
|
||||
}
|
||||
|
||||
function normalizeSlug(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTrackAddress(address = '') {
|
||||
const raw = String(address || '').trim();
|
||||
const parts = raw.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
if (Number(parts[0]) !== TRACK_KIND) return null;
|
||||
const pubkey = String(parts[1] || '').trim().toLowerCase();
|
||||
const dTag = parts.slice(2).join(':').trim();
|
||||
if (!/^[a-f0-9]{64}$/i.test(pubkey) || !dTag) return null;
|
||||
return {
|
||||
kind: TRACK_KIND,
|
||||
pubkey,
|
||||
dTag,
|
||||
address: `${TRACK_KIND}:${pubkey}:${dTag}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrackTag(track) {
|
||||
const id = String(track?.id || '').trim();
|
||||
const parsed = parseTrackAddress(id);
|
||||
if (parsed) return ['a', parsed.address];
|
||||
|
||||
const rawPubkey = String(track?.raw?.pubkey || track?.pubkey || '').trim().toLowerCase();
|
||||
const dTag = String(
|
||||
track?.dTag
|
||||
|| getTagValue(track?.raw?.tags, 'd')
|
||||
|| track?.identifier
|
||||
|| ''
|
||||
).trim();
|
||||
|
||||
if (/^[a-f0-9]{64}$/i.test(rawPubkey) && dTag) {
|
||||
return ['a', `${TRACK_KIND}:${rawPubkey}:${dTag}`];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTrackCache(track) {
|
||||
const id = String(track?.id || '').trim();
|
||||
if (!id) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
title: typeof track?.title === 'string' ? track.title : '',
|
||||
artist: typeof track?.artist === 'string' ? track.artist : '',
|
||||
duration: Number.isFinite(track?.duration) ? Number(track.duration) : 0,
|
||||
cover: typeof track?.cover === 'string' ? track.cover : '',
|
||||
albumTitle: typeof track?.albumTitle === 'string' ? track.albumTitle : '',
|
||||
streamUrl: typeof track?.streamUrl === 'string' ? track.streamUrl : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlaylistIdentifier(title = '') {
|
||||
const slug = normalizeSlug(title);
|
||||
if (!slug) return `${Date.now()}`;
|
||||
return `${slug}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export function isMusicPlaylistEvent(event) {
|
||||
if (!event || event.kind !== PLAYLIST_KIND || !Array.isArray(event.tags)) return false;
|
||||
|
||||
const topics = getTagValues(event.tags, 't').map((t) => t.toLowerCase());
|
||||
if (topics.includes('gruuv') || topics.includes('music') || topics.includes('playlist')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasIdentifier = Boolean(getTagValue(event.tags, 'd'));
|
||||
const hasTitle = Boolean(getTagValue(event.tags, 'title'));
|
||||
const hasTrackRefs = getTagValues(event.tags, 'a').some((value) => Boolean(parseTrackAddress(value)));
|
||||
|
||||
return hasIdentifier && (hasTitle || hasTrackRefs);
|
||||
}
|
||||
|
||||
export function buildPlaylistEvent(playlist, createdAt = Math.floor(Date.now() / 1000)) {
|
||||
const identifier = playlist?.identifier || playlist?.id || createPlaylistIdentifier(playlist?.title || '');
|
||||
const title = typeof playlist?.title === 'string' ? playlist.title.trim() : '';
|
||||
const description = typeof playlist?.description === 'string' ? playlist.description.trim() : '';
|
||||
const image = typeof playlist?.image === 'string' ? playlist.image.trim() : '';
|
||||
const banner = typeof playlist?.banner === 'string' ? playlist.banner.trim() : '';
|
||||
|
||||
const inputTracks = Array.isArray(playlist?.tracks) ? playlist.tracks : [];
|
||||
const cacheTracks = inputTracks.map(buildTrackCache).filter(Boolean);
|
||||
|
||||
const seen = new Set();
|
||||
const trackTags = [];
|
||||
|
||||
for (const track of inputTracks) {
|
||||
const tag = buildTrackTag(track);
|
||||
if (!tag) continue;
|
||||
const value = tag[1];
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
trackTags.push(tag);
|
||||
}
|
||||
|
||||
const tags = [
|
||||
['d', identifier],
|
||||
['title', title || 'Untitled playlist'],
|
||||
['alt', `Playlist: ${title || 'Untitled playlist'}`],
|
||||
['t', 'gruuv'],
|
||||
['t', 'music'],
|
||||
['t', 'playlist'],
|
||||
];
|
||||
|
||||
if (description) tags.push(['description', description]);
|
||||
if (image) tags.push(['image', image]);
|
||||
if (banner) tags.push(['banner', banner]);
|
||||
tags.push(...trackTags);
|
||||
|
||||
return {
|
||||
created_at: createdAt,
|
||||
kind: PLAYLIST_KIND,
|
||||
tags,
|
||||
content: JSON.stringify({ tracks: cacheTracks }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePlaylistEvent(event) {
|
||||
if (!event || event.kind !== PLAYLIST_KIND) return null;
|
||||
if (!Array.isArray(event.tags)) return null;
|
||||
|
||||
const identifier = getTagValue(event.tags, 'd') || '';
|
||||
const title = getTagValue(event.tags, 'title') || 'Untitled playlist';
|
||||
const description = getTagValue(event.tags, 'description') || '';
|
||||
const image = getTagValue(event.tags, 'image') || '';
|
||||
const banner = getTagValue(event.tags, 'banner') || '';
|
||||
|
||||
const aValues = getTagValues(event.tags, 'a');
|
||||
const trackIdsFromTags = aValues
|
||||
.map((v) => parseTrackAddress(v)?.address || '')
|
||||
.filter(Boolean);
|
||||
|
||||
const parsedContent = safeJsonParse(event.content || '', {});
|
||||
const cacheTracks = Array.isArray(parsedContent?.tracks)
|
||||
? parsedContent.tracks.map(buildTrackCache).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const cachedById = new Map(cacheTracks.map((t) => [String(t.id), t]));
|
||||
const trackIds = [...new Set(trackIdsFromTags)];
|
||||
|
||||
const tracks = trackIds.map((id) => {
|
||||
const cached = cachedById.get(id);
|
||||
if (cached) return cached;
|
||||
return { id, title: '', artist: '', duration: 0, cover: '', albumTitle: '', streamUrl: '' };
|
||||
});
|
||||
|
||||
return {
|
||||
eventId: event.id || '',
|
||||
pubkey: event.pubkey || '',
|
||||
createdAt: Number(event.created_at) || 0,
|
||||
identifier,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
banner,
|
||||
tracks,
|
||||
trackIds,
|
||||
raw: event,
|
||||
};
|
||||
}
|
||||
|
||||
export function playlistEventAddress(pubkey, identifier) {
|
||||
if (!pubkey || !identifier) return '';
|
||||
return `${PLAYLIST_KIND}:${pubkey}:${identifier}`;
|
||||
}
|
||||
|
||||
export { PLAYLIST_KIND, parseTrackAddress, buildTrackTag };
|
||||
204
www/js/gruuv-upload.mjs
Normal file
204
www/js/gruuv-upload.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
import { publishEvent, getPubkey } from './init-ndk.mjs';
|
||||
import { calculateSHA256, createBlossomAuth } from './blossom-api.mjs';
|
||||
import { getBlossomServers } from './blossom-ui.mjs';
|
||||
|
||||
const TRACK_KIND = 36787;
|
||||
|
||||
function normalizeText(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function extensionFromName(name = '') {
|
||||
const base = String(name || '').trim();
|
||||
const idx = base.lastIndexOf('.');
|
||||
if (idx < 0) return '';
|
||||
return base.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function inferFormat(file) {
|
||||
const ext = extensionFromName(file?.name || '');
|
||||
if (ext) return ext;
|
||||
|
||||
const mime = normalizeText(file?.type || '').toLowerCase();
|
||||
if (mime.includes('flac')) return 'flac';
|
||||
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3';
|
||||
if (mime.includes('aac')) return 'aac';
|
||||
if (mime.includes('ogg')) return 'ogg';
|
||||
if (mime.includes('wav')) return 'wav';
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
function inferMime(file) {
|
||||
const mime = normalizeText(file?.type || '');
|
||||
if (mime) return mime;
|
||||
|
||||
const format = inferFormat(file);
|
||||
if (format === 'mp3') return 'audio/mpeg';
|
||||
if (format === 'flac') return 'audio/flac';
|
||||
if (format === 'ogg') return 'audio/ogg';
|
||||
if (format === 'aac') return 'audio/aac';
|
||||
if (format === 'wav') return 'audio/wav';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function selectUploadServer() {
|
||||
const servers = getBlossomServers();
|
||||
const healthyUpload = servers.find((s) => s?.healthy && s?.upload);
|
||||
if (healthyUpload?.url) return String(healthyUpload.url).trim().replace(/\/$/, '');
|
||||
|
||||
const anyUpload = servers.find((s) => s?.upload);
|
||||
if (anyUpload?.url) return String(anyUpload.url).trim().replace(/\/$/, '');
|
||||
|
||||
const healthyAny = servers.find((s) => s?.healthy);
|
||||
if (healthyAny?.url) return String(healthyAny.url).trim().replace(/\/$/, '');
|
||||
|
||||
if (servers[0]?.url) return String(servers[0].url).trim().replace(/\/$/, '');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getAudioDurationSeconds(file) {
|
||||
if (!(file instanceof File || file instanceof Blob)) return 0;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const duration = await new Promise((resolve) => {
|
||||
const audio = document.createElement('audio');
|
||||
const cleanup = () => {
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
};
|
||||
|
||||
audio.preload = 'metadata';
|
||||
audio.onloadedmetadata = () => {
|
||||
const d = Number(audio.duration);
|
||||
cleanup();
|
||||
resolve(Number.isFinite(d) && d > 0 ? d : 0);
|
||||
};
|
||||
audio.onerror = () => {
|
||||
cleanup();
|
||||
resolve(0);
|
||||
};
|
||||
audio.src = objectUrl;
|
||||
});
|
||||
|
||||
return Math.round(duration);
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function makeDTag({ title, artist, sha256 }) {
|
||||
const safeTitle = normalizeText(title)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
const safeArtist = normalizeText(artist)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32);
|
||||
|
||||
const prefix = [safeArtist, safeTitle].filter(Boolean).join('-') || 'track';
|
||||
return `${prefix}-${String(sha256 || '').slice(0, 8)}-${Date.now()}`;
|
||||
}
|
||||
|
||||
async function uploadFileToBlossom(file, serverUrl, sha256) {
|
||||
const authToken = await createBlossomAuth('upload', sha256, `Upload ${file.name || 'audio'}`);
|
||||
const res = await fetch(`${serverUrl}/upload`, {
|
||||
method: 'PUT',
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': inferMime(file),
|
||||
Authorization: `Nostr ${authToken}`,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const url = normalizeText(payload?.url || payload?.blob?.url || '');
|
||||
return {
|
||||
uploadUrl: url || `${serverUrl}/${sha256}`,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadGruuvTrack(file, metadata = {}, options = {}) {
|
||||
if (!file) throw new Error('No file selected');
|
||||
|
||||
const pubkey = await getPubkey();
|
||||
if (!pubkey) throw new Error('Not authenticated');
|
||||
|
||||
const serverUrl = normalizeText(options.serverUrl || selectUploadServer());
|
||||
if (!serverUrl) throw new Error('No Blossom server available for upload');
|
||||
|
||||
const sha256 = await calculateSHA256(file);
|
||||
const uploadResult = await uploadFileToBlossom(file, serverUrl, sha256);
|
||||
|
||||
const title = normalizeText(metadata.title || file.name?.replace(/\.[^.]+$/, '') || 'Untitled');
|
||||
const artist = normalizeText(metadata.artist || 'Unknown artist');
|
||||
const album = normalizeText(metadata.album || 'Uploads');
|
||||
const image = normalizeText(metadata.image || '');
|
||||
const genre = normalizeText(metadata.genre || '');
|
||||
const released = normalizeText(metadata.released || '');
|
||||
const trackNumber = normalizeText(metadata.trackNumber || '');
|
||||
const discNumber = normalizeText(metadata.discNumber || '');
|
||||
|
||||
const duration = Number.isFinite(metadata.duration)
|
||||
? Number(metadata.duration)
|
||||
: await getAudioDurationSeconds(file);
|
||||
|
||||
const format = inferFormat(file);
|
||||
const mime = inferMime(file);
|
||||
const dTag = normalizeText(metadata.dTag || makeDTag({ title, artist, sha256 }));
|
||||
|
||||
const tags = [
|
||||
['d', dTag],
|
||||
['url', uploadResult.uploadUrl],
|
||||
['title', title],
|
||||
['artist', artist],
|
||||
['album', album],
|
||||
['duration', String(Math.max(0, Math.round(duration || 0)))],
|
||||
['size', String(file.size || 0)],
|
||||
['alt', `Song: ${title} - ${artist}`],
|
||||
['format', format],
|
||||
['m', mime],
|
||||
['x', sha256],
|
||||
['t', 'music'],
|
||||
['t', 'gruuv'],
|
||||
];
|
||||
|
||||
if (image) tags.push(['image', image]);
|
||||
if (genre) {
|
||||
tags.push(['genre', genre]);
|
||||
tags.push(['t', genre.toLowerCase()]);
|
||||
}
|
||||
if (released) tags.push(['released', released]);
|
||||
if (trackNumber) tags.push(['track_number', trackNumber]);
|
||||
if (discNumber && discNumber !== '1') tags.push(['disc_number', discNumber]);
|
||||
|
||||
const event = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: TRACK_KIND,
|
||||
tags,
|
||||
content: `Listen to my song - ${title} by ${artist}`,
|
||||
};
|
||||
|
||||
const publishResult = await publishEvent(event);
|
||||
|
||||
return {
|
||||
file,
|
||||
pubkey,
|
||||
sha256,
|
||||
url: uploadResult.uploadUrl,
|
||||
dTag,
|
||||
event,
|
||||
publishResult,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.16",
|
||||
"VERSION_NUMBER": "0.7.16",
|
||||
"BUILD_DATE": "2026-05-29T14:58:32.174Z"
|
||||
"VERSION": "v0.7.29",
|
||||
"VERSION_NUMBER": "0.7.29",
|
||||
"BUILD_DATE": "2026-06-26T00:29:51.222Z"
|
||||
}
|
||||
|
||||
5600
www/music-greyscale.html
Normal file
5600
www/music-greyscale.html
Normal file
File diff suppressed because it is too large
Load Diff
396
www/music.html
396
www/music.html
@@ -221,6 +221,38 @@
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
|
||||
#musicUploadSection {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
#musicUploadSection input,
|
||||
#musicUploadSection button {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
background: var(--secondary-color);
|
||||
color: var(--primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#musicUploadSection button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#musicUploadStatus {
|
||||
font-size: 11px;
|
||||
color: var(--muted-color);
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
#musicPlaylistProfile {
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
@@ -1136,7 +1168,10 @@
|
||||
<div class="musicSidebarTitle">MY PLAYLISTS</div>
|
||||
<ul id="musicMyPlaylistsList" class="musicPlaylistList"></ul>
|
||||
|
||||
<div class="musicSidebarTitle">FRIENDS</div>
|
||||
<div class="musicSidebarTitle" style="display:flex; align-items:center; justify-content:space-between; gap:8px;">
|
||||
<span>FRIENDS</span>
|
||||
<button id="musicLoadFriendsBtn" class="btn musicMiniBtn" type="button">Load</button>
|
||||
</div>
|
||||
<ul id="musicFriendPlaylistsList" class="musicPlaylistList"></ul>
|
||||
|
||||
<div class="musicSidebarTitle">AI IMPORT</div>
|
||||
@@ -1152,6 +1187,16 @@
|
||||
<div id="musicImportStatus"></div>
|
||||
<div id="musicImportResults"></div>
|
||||
</section>
|
||||
|
||||
<div class="musicSidebarTitle">UPLOAD TRACK</div>
|
||||
<section id="musicUploadSection">
|
||||
<input id="musicUploadFile" type="file" accept="audio/*" />
|
||||
<input id="musicUploadTitle" type="text" placeholder="Title (optional)" />
|
||||
<input id="musicUploadArtist" type="text" placeholder="Artist (optional)" />
|
||||
<input id="musicUploadAlbum" type="text" placeholder="Album (optional)" />
|
||||
<button id="musicUploadBtn" class="btn" type="button">Upload to Gruuv</button>
|
||||
<div id="musicUploadStatus">Sign in to upload.</div>
|
||||
</section>
|
||||
</aside>
|
||||
<div class="musicGutter resizableColumnsGutter" data-gutter-index="0" aria-hidden="true"></div>
|
||||
|
||||
@@ -1368,16 +1413,17 @@
|
||||
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 { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
import { GruuvAPI } from './js/gruuv-api.mjs';
|
||||
import { SimplePlayer } from './js/greyscale-player.mjs';
|
||||
import { sendAiChatJson } from './js/ai-chat.mjs';
|
||||
import { uploadGruuvTrack } from './js/gruuv-upload.mjs';
|
||||
import {
|
||||
PLAYLIST_KIND,
|
||||
buildPlaylistEvent,
|
||||
createPlaylistIdentifier,
|
||||
parsePlaylistEvent,
|
||||
isMusicPlaylistEvent,
|
||||
} from './js/greyscale-playlists.mjs';
|
||||
} from './js/gruuv-playlists.mjs';
|
||||
import { mountDotMenu } from './js/dot-menu.mjs';
|
||||
import { initResizableColumns } from './js/resizable-columns.mjs';
|
||||
import {
|
||||
@@ -1466,6 +1512,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
playlistDescriptionInput: document.getElementById('musicPlaylistDescriptionInput'),
|
||||
myPlaylistsList: document.getElementById('musicMyPlaylistsList'),
|
||||
friendPlaylistsList: document.getElementById('musicFriendPlaylistsList'),
|
||||
loadFriendsBtn: document.getElementById('musicLoadFriendsBtn'),
|
||||
importDropZone: document.getElementById('musicImportDropZone'),
|
||||
importInput: document.getElementById('musicImportInput'),
|
||||
importProcessing: document.getElementById('musicImportProcessing'),
|
||||
@@ -1483,9 +1530,15 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
viewerPubkeyInput: document.getElementById('musicViewerPubkeyInput'),
|
||||
viewerLoadBtn: document.getElementById('musicViewerLoadBtn'),
|
||||
viewerInfo: document.getElementById('musicViewerInfo'),
|
||||
uploadFileInput: document.getElementById('musicUploadFile'),
|
||||
uploadTitleInput: document.getElementById('musicUploadTitle'),
|
||||
uploadArtistInput: document.getElementById('musicUploadArtist'),
|
||||
uploadAlbumInput: document.getElementById('musicUploadAlbum'),
|
||||
uploadBtn: document.getElementById('musicUploadBtn'),
|
||||
uploadStatus: document.getElementById('musicUploadStatus'),
|
||||
};
|
||||
|
||||
const musicApi = new GreyscaleAPI();
|
||||
const musicApi = new GruuvAPI();
|
||||
const musicPlayer = new SimplePlayer({
|
||||
audio: musicEls.audio,
|
||||
progressEl: musicEls.progress,
|
||||
@@ -1502,6 +1555,8 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
|
||||
const myPlaylists = new Map();
|
||||
const friendPlaylists = new Map();
|
||||
let friendsFeedEnabled = false;
|
||||
let playlistSubscription = null;
|
||||
let musicQueue = [];
|
||||
let queueCurrentIndex = -1;
|
||||
let musicResultsMode = 'search';
|
||||
@@ -1534,6 +1589,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
let pendingShareUrl = '';
|
||||
let pendingShareArtUrl = '';
|
||||
let pendingShareLabel = '';
|
||||
let isGruuvUploadRunning = false;
|
||||
|
||||
const BLANK_IMAGE_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
|
||||
const MUSIC_COLUMN_STORAGE_KEY = 'music-column-widths:v1';
|
||||
@@ -1605,6 +1661,76 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
musicEls.status.dataset.type = type;
|
||||
}
|
||||
|
||||
function setUploadStatus(text) {
|
||||
if (!musicEls.uploadStatus) return;
|
||||
musicEls.uploadStatus.textContent = String(text || '').trim();
|
||||
}
|
||||
|
||||
function updateUploadUiState() {
|
||||
const canUpload = Boolean(isAuthenticated && currentPubkey);
|
||||
if (musicEls.uploadBtn) {
|
||||
musicEls.uploadBtn.disabled = !canUpload || isGruuvUploadRunning;
|
||||
}
|
||||
if (musicEls.uploadFileInput) {
|
||||
musicEls.uploadFileInput.disabled = isGruuvUploadRunning;
|
||||
}
|
||||
if (musicEls.uploadTitleInput) {
|
||||
musicEls.uploadTitleInput.disabled = isGruuvUploadRunning;
|
||||
}
|
||||
if (musicEls.uploadArtistInput) {
|
||||
musicEls.uploadArtistInput.disabled = isGruuvUploadRunning;
|
||||
}
|
||||
if (musicEls.uploadAlbumInput) {
|
||||
musicEls.uploadAlbumInput.disabled = isGruuvUploadRunning;
|
||||
}
|
||||
|
||||
if (!canUpload && !isGruuvUploadRunning) {
|
||||
setUploadStatus('Sign in to upload.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGruuvUploadClick() {
|
||||
if (isGruuvUploadRunning) return;
|
||||
|
||||
try {
|
||||
await promptLoginIfNeeded();
|
||||
} catch (error) {
|
||||
setUploadStatus(`Login required: ${error?.message || 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = musicEls.uploadFileInput?.files?.[0];
|
||||
if (!file) {
|
||||
setUploadStatus('Choose an audio file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
isGruuvUploadRunning = true;
|
||||
updateUploadUiState();
|
||||
setUploadStatus(`Uploading ${file.name}...`);
|
||||
|
||||
try {
|
||||
const result = await uploadGruuvTrack(file, {
|
||||
title: musicEls.uploadTitleInput?.value?.trim() || '',
|
||||
artist: musicEls.uploadArtistInput?.value?.trim() || '',
|
||||
album: musicEls.uploadAlbumInput?.value?.trim() || '',
|
||||
});
|
||||
|
||||
const title = result?.event?.tags?.find((t) => t?.[0] === 'title')?.[1] || file.name;
|
||||
setUploadStatus(`Uploaded: ${title}`);
|
||||
setMusicStatus(`Uploaded track to Gruuv: ${title}`, 'ok');
|
||||
|
||||
if (musicEls.uploadFileInput) musicEls.uploadFileInput.value = '';
|
||||
} catch (error) {
|
||||
console.error('[music upload] upload failed', error);
|
||||
setUploadStatus(`Upload failed: ${error?.message || 'Unknown error'}`);
|
||||
setMusicStatus(`Upload failed: ${error?.message || 'Unknown error'}`, 'error');
|
||||
} finally {
|
||||
isGruuvUploadRunning = false;
|
||||
updateUploadUiState();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuthModeFromUrl() {
|
||||
const explicitAuth = getExplicitAuthModeFromUrl();
|
||||
if (explicitAuth) return explicitAuth;
|
||||
@@ -1687,6 +1813,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
if (musicEls.playlistNameInput) {
|
||||
musicEls.playlistNameInput.disabled = isReadOnlyTargetMode();
|
||||
}
|
||||
updateUploadUiState();
|
||||
}
|
||||
|
||||
async function initializeAuthentication(mode) {
|
||||
@@ -1743,6 +1870,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
await initializeAuthenticatedPageFeatures();
|
||||
syncLoggedInNpubToUrl();
|
||||
updateViewerControls();
|
||||
updateUploadUiState();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1839,7 +1967,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
function normalizeQueueTrack(track) {
|
||||
if (!track || typeof track !== 'object') return null;
|
||||
return {
|
||||
id: track.id,
|
||||
id: String(track.id || ''),
|
||||
title: track.title || 'Unknown title',
|
||||
artist: track.artist || 'Unknown artist',
|
||||
duration: track.duration || 0,
|
||||
@@ -1847,6 +1975,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
albumTitle: track.albumTitle || '',
|
||||
albumId: track.albumId || track.album?.id || track.raw?.album?.id || '',
|
||||
artistId: track.artistId || track.artist?.id || track.raw?.artist?.id || track.raw?.artists?.[0]?.id || '',
|
||||
streamUrl: track.streamUrl || track.raw?.streamUrl || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2405,6 +2534,14 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
}
|
||||
|
||||
function renderFriendPlaylists() {
|
||||
if (!friendsFeedEnabled && !isExternalTargetMode()) {
|
||||
destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists);
|
||||
if (musicEls.loadFriendsBtn) musicEls.loadFriendsBtn.textContent = 'Load';
|
||||
musicEls.friendPlaylistsList.innerHTML = '<li class="musicPlaylistMeta">Friend playlists are paused. Click Load to fetch.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (musicEls.loadFriendsBtn) musicEls.loadFriendsBtn.textContent = 'Loaded';
|
||||
const items = Array.from(friendPlaylists.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
if (!items.length) {
|
||||
destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists);
|
||||
@@ -2712,14 +2849,20 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
return false;
|
||||
}
|
||||
|
||||
const exists = (playlist.tracks || []).some((item) => Number(item?.id) === Number(track?.id));
|
||||
const trackId = String(track?.id || '').trim();
|
||||
if (!trackId) {
|
||||
if (!silent) setMusicStatus('Track is missing an id.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const exists = (playlist.tracks || []).some((item) => String(item?.id || '').trim() === trackId);
|
||||
if (exists) {
|
||||
if (!silent) setMusicStatus('Track already in playlist.', 'neutral');
|
||||
return false;
|
||||
}
|
||||
|
||||
playlist.tracks.push({
|
||||
id: track.id,
|
||||
id: trackId,
|
||||
title: track.title || '',
|
||||
artist: track.artist || '',
|
||||
duration: track.duration || 0,
|
||||
@@ -3177,13 +3320,13 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIds = new Set((playlist.tracks || []).map((track) => Number(track?.id)).filter((id) => Number.isFinite(id)));
|
||||
const existingIds = new Set((playlist.tracks || []).map((track) => String(track?.id || '').trim()).filter(Boolean));
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const track of importMatchedTracks) {
|
||||
const id = Number(track?.id);
|
||||
if (!Number.isFinite(id) || existingIds.has(id)) {
|
||||
const id = String(track?.id || '').trim();
|
||||
if (!id || existingIds.has(id)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -3242,55 +3385,192 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
clearPlaylistDropIndicator();
|
||||
}
|
||||
|
||||
function hydratePlaylistFromEvent(event, isFriend = false) {
|
||||
async function resolveTracksByAddresses(addresses = []) {
|
||||
const ids = Array.isArray(addresses) ? addresses.filter(Boolean) : [];
|
||||
if (!ids.length) return new Map();
|
||||
|
||||
if (musicApi && typeof musicApi.getTracksByAddresses === 'function') {
|
||||
return musicApi.getTracksByAddresses(ids);
|
||||
}
|
||||
|
||||
console.warn('[music] Falling back to coordinate search: musicApi.getTracksByAddresses() unavailable');
|
||||
const map = new Map();
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const result = await musicApi.searchTracks(id);
|
||||
const items = Array.isArray(result?.items) ? result.items : [];
|
||||
const match = items.find((item) => String(item?.id || '').trim() === id) || items[0] || null;
|
||||
if (match) {
|
||||
const prepared = musicApi.prepareTrack(match) || match;
|
||||
map.set(id, prepared);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[music] resolveTracksByAddresses fallback search failed', { id, error });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function hydratePlaylistFromEvent(event, isFriend = false) {
|
||||
const parsed = parsePlaylistEvent(event);
|
||||
if (!parsed || !isMusicPlaylistEvent(event)) return;
|
||||
|
||||
if (isFriend) {
|
||||
const key = getFriendPlaylistRouteKey(parsed);
|
||||
if (key) friendPlaylists.set(key, parsed);
|
||||
renderFriendPlaylists();
|
||||
} else {
|
||||
const existing = myPlaylists.get(parsed.identifier);
|
||||
myPlaylists.set(parsed.identifier, {
|
||||
identifier: parsed.identifier,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
image: parsed.image,
|
||||
banner: parsed.banner || '',
|
||||
tracks: parsed.tracks,
|
||||
pubkey: parsed.pubkey,
|
||||
eventId: parsed.eventId,
|
||||
createdAt: parsed.createdAt,
|
||||
raw: parsed.raw,
|
||||
...(existing || {}),
|
||||
});
|
||||
savePlaylistsLocal();
|
||||
renderMyPlaylists();
|
||||
}
|
||||
const commitPlaylist = (playlist, expectedEventId = '') => {
|
||||
const shouldSkipAsStale = (existing) => {
|
||||
if (!existing) return false;
|
||||
const existingCreatedAt = Number(existing?.createdAt || 0);
|
||||
const incomingCreatedAt = Number(playlist?.createdAt || 0);
|
||||
const existingEventId = String(existing?.eventId || '').trim();
|
||||
const incomingEventId = String(playlist?.eventId || '').trim();
|
||||
|
||||
if (
|
||||
expectedEventId
|
||||
&& existingEventId
|
||||
&& existingEventId !== expectedEventId
|
||||
&& existingCreatedAt >= incomingCreatedAt
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!expectedEventId
|
||||
&& existingEventId
|
||||
&& incomingEventId
|
||||
&& existingEventId !== incomingEventId
|
||||
&& existingCreatedAt > incomingCreatedAt
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const urlEpisode = String(getEpisodeFromUrl() || '').trim();
|
||||
if (urlEpisode && parsed.identifier === urlEpisode && !selectedPlaylistId) {
|
||||
if (isFriend) {
|
||||
const key = getFriendPlaylistRouteKey(parsed);
|
||||
if (key) openFriendPlaylistByKey(key);
|
||||
const key = getFriendPlaylistRouteKey(playlist);
|
||||
if (!key) return;
|
||||
const existing = friendPlaylists.get(key) || null;
|
||||
if (shouldSkipAsStale(existing)) return;
|
||||
friendPlaylists.set(key, playlist);
|
||||
renderFriendPlaylists();
|
||||
} else {
|
||||
openMyPlaylistByIdentifier(parsed.identifier);
|
||||
}
|
||||
}
|
||||
const existing = myPlaylists.get(playlist.identifier) || null;
|
||||
if (shouldSkipAsStale(existing)) return;
|
||||
|
||||
if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === parsed.identifier) {
|
||||
activePlaylistView = { ...activePlaylistView, playlist: parsed };
|
||||
renderPlaylistProfileCard();
|
||||
myPlaylists.set(playlist.identifier, {
|
||||
...(existing || {}),
|
||||
identifier: playlist.identifier,
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
image: playlist.image,
|
||||
banner: playlist.banner || '',
|
||||
tracks: playlist.tracks,
|
||||
pubkey: playlist.pubkey,
|
||||
eventId: playlist.eventId,
|
||||
createdAt: playlist.createdAt,
|
||||
raw: playlist.raw,
|
||||
});
|
||||
savePlaylistsLocal();
|
||||
renderMyPlaylists();
|
||||
}
|
||||
|
||||
const activeKey = getFriendPlaylistRouteKey(playlist);
|
||||
const shouldRefreshVisibleResults = (
|
||||
(musicResultsMode === 'my-playlist' && musicResultsPlaylistId === playlist.identifier)
|
||||
|| (musicResultsMode === 'friend-playlist' && !!activeKey && musicResultsPlaylistId === activeKey)
|
||||
);
|
||||
if (shouldRefreshVisibleResults) {
|
||||
musicTracks = Array.isArray(playlist.tracks) ? [...playlist.tracks] : [];
|
||||
renderMusicResults(musicTracks);
|
||||
}
|
||||
|
||||
const urlEpisode = String(getEpisodeFromUrl() || '').trim();
|
||||
if (urlEpisode && playlist.identifier === urlEpisode && !selectedPlaylistId) {
|
||||
if (isFriend) {
|
||||
const key = getFriendPlaylistRouteKey(playlist);
|
||||
if (key) openFriendPlaylistByKey(key);
|
||||
} else {
|
||||
openMyPlaylistByIdentifier(playlist.identifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === playlist.identifier) {
|
||||
activePlaylistView = { ...activePlaylistView, playlist };
|
||||
renderPlaylistProfileCard();
|
||||
}
|
||||
};
|
||||
|
||||
commitPlaylist(parsed);
|
||||
|
||||
const trackIds = Array.isArray(parsed.trackIds) ? parsed.trackIds.filter(Boolean) : [];
|
||||
console.log('[music] hydratePlaylistFromEvent parsed', {
|
||||
identifier: parsed.identifier,
|
||||
eventId: parsed.eventId,
|
||||
createdAt: parsed.createdAt,
|
||||
trackIdsCount: trackIds.length,
|
||||
trackIds,
|
||||
});
|
||||
if (!trackIds.length) return;
|
||||
|
||||
try {
|
||||
const resolvedByAddress = await resolveTracksByAddresses(trackIds);
|
||||
console.log('[music] hydratePlaylistFromEvent resolved', {
|
||||
identifier: parsed.identifier,
|
||||
requested: trackIds.length,
|
||||
resolved: resolvedByAddress?.size || 0,
|
||||
});
|
||||
const hydratedTracks = trackIds.map((id, index) => {
|
||||
const base = parsed.tracks?.[index] || { id };
|
||||
const resolved = resolvedByAddress?.get?.(id) || null;
|
||||
if (resolved) {
|
||||
const prepared = musicApi.prepareTrack(resolved) || resolved;
|
||||
return {
|
||||
...base,
|
||||
...prepared,
|
||||
id,
|
||||
unavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
id,
|
||||
title: String(base?.title || '').trim() || 'Unavailable track',
|
||||
artist: String(base?.artist || '').trim() || 'Unknown artist',
|
||||
duration: Number(base?.duration) || 0,
|
||||
cover: String(base?.cover || '').trim(),
|
||||
albumTitle: String(base?.albumTitle || '').trim(),
|
||||
streamUrl: String(base?.streamUrl || '').trim(),
|
||||
unavailable: true,
|
||||
};
|
||||
});
|
||||
|
||||
commitPlaylist({
|
||||
...parsed,
|
||||
tracks: hydratedTracks,
|
||||
}, parsed.eventId || '');
|
||||
} catch (error) {
|
||||
console.warn('[music] Failed to hydrate playlist tracks from a-tags', {
|
||||
playlist: parsed.identifier,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeAllPlaylists() {
|
||||
const filter = { kinds: [PLAYLIST_KIND], '#t': ['music'], limit: 600 };
|
||||
const filter = { kinds: [PLAYLIST_KIND], limit: 600 };
|
||||
if (isExternalTargetMode()) {
|
||||
filter.authors = [targetPubkey];
|
||||
} else if (!friendsFeedEnabled && currentPubkey) {
|
||||
filter.authors = [currentPubkey];
|
||||
}
|
||||
subscribe(
|
||||
|
||||
try {
|
||||
playlistSubscription?.close?.();
|
||||
} catch {
|
||||
// ignore stale subscription handle errors
|
||||
}
|
||||
|
||||
playlistSubscription = subscribe(
|
||||
filter,
|
||||
{ closeOnEose: false, cacheUsage: 'PARALLEL' }
|
||||
);
|
||||
@@ -3301,6 +3581,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
if (!evt || typeof evt !== 'object') return;
|
||||
if (evt.kind !== PLAYLIST_KIND || !isMusicPlaylistEvent(evt)) return;
|
||||
if (isExternalTargetMode() && evt.pubkey !== targetPubkey) return;
|
||||
if (!friendsFeedEnabled && !isExternalTargetMode() && evt.pubkey !== currentPubkey) return;
|
||||
|
||||
const treatAsMine = Boolean(
|
||||
currentPubkey
|
||||
@@ -4347,14 +4628,14 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
title: track.title || '',
|
||||
});
|
||||
|
||||
const qualityCandidates = ['HIGH', 'MP3_320', 'LOSSLESS'];
|
||||
const qualityCandidates = ['DIRECT'];
|
||||
let lastError = null;
|
||||
|
||||
for (const quality of qualityCandidates) {
|
||||
try {
|
||||
console.debug('[music][download] requesting stream', { trackId: track.id, quality });
|
||||
|
||||
const stream = await musicApi.getTrackStream(track.id, quality);
|
||||
const stream = await musicApi.getTrackStream(track.id);
|
||||
const streamUrl = stream?.streamUrl;
|
||||
if (!streamUrl) {
|
||||
lastError = new Error(`No stream URL for quality ${quality}`);
|
||||
@@ -4583,6 +4864,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
installImageFallbacks();
|
||||
|
||||
initMusicResizableColumns();
|
||||
updateUploadUiState();
|
||||
|
||||
musicPlayer.onTrackChanged = (track) => {
|
||||
updateMusicPlayerMeta(track);
|
||||
@@ -4606,6 +4888,10 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
applyTargetPubkeyFromInput();
|
||||
});
|
||||
|
||||
musicEls.uploadBtn?.addEventListener('click', async () => {
|
||||
await handleGruuvUploadClick();
|
||||
});
|
||||
|
||||
musicEls.results.addEventListener('click', async (event) => {
|
||||
|
||||
const playAlbumNow = event.target.closest('[data-play-album-now]');
|
||||
@@ -4832,6 +5118,15 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
navigate(`/playlist/${encodeURIComponent(pubkey)}/${encodeURIComponent(identifier)}`);
|
||||
});
|
||||
|
||||
musicEls.loadFriendsBtn?.addEventListener('click', () => {
|
||||
if (isExternalTargetMode()) return;
|
||||
if (friendsFeedEnabled) return;
|
||||
friendsFeedEnabled = true;
|
||||
setMusicStatus('Loading friend playlists…', 'neutral');
|
||||
renderFriendPlaylists();
|
||||
subscribeAllPlaylists();
|
||||
});
|
||||
|
||||
musicEls.playlistNameInput?.addEventListener('keydown', async (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
@@ -5125,11 +5420,16 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
}
|
||||
loadQueueLocal();
|
||||
updateViewerControls();
|
||||
friendsFeedEnabled = isExternalTargetMode();
|
||||
if (musicEls.loadFriendsBtn) {
|
||||
musicEls.loadFriendsBtn.disabled = isExternalTargetMode();
|
||||
musicEls.loadFriendsBtn.textContent = isExternalTargetMode() ? 'Auto' : 'Load';
|
||||
}
|
||||
renderMyPlaylists();
|
||||
renderFriendPlaylists();
|
||||
renderQueue();
|
||||
subscribeAllPlaylists();
|
||||
window.addEventListener('ndkEvent', handleMusicNdkEvent);
|
||||
subscribeAllPlaylists();
|
||||
|
||||
if (!window.location.hash) {
|
||||
const episodeRoute = getEpisodeRouteFromUrlContext();
|
||||
|
||||
@@ -1179,6 +1179,7 @@ function startRelayHealthCheck() {
|
||||
class MessageBasedSigner {
|
||||
constructor() {
|
||||
this.pubkey = null;
|
||||
this.signedEventsBySig = new Map(); // sig -> signed raw event from signer page
|
||||
}
|
||||
|
||||
async user() {
|
||||
@@ -1196,12 +1197,59 @@ class MessageBasedSigner {
|
||||
|
||||
async sign(event) {
|
||||
console.log('[Worker] MessageBasedSigner: Signing event kind:', event.kind);
|
||||
|
||||
|
||||
// Request signing from page
|
||||
const signedEvent = await this.requestFromPage('signEvent', { event });
|
||||
|
||||
|
||||
const sig = String(signedEvent?.sig || '').trim();
|
||||
if (!sig) {
|
||||
throw new Error('signEvent returned no signature');
|
||||
}
|
||||
|
||||
// Keep a snapshot so caller can align NDKEvent fields to exactly what was signed.
|
||||
const snapshot = {
|
||||
id: String(signedEvent?.id || ''),
|
||||
pubkey: String(signedEvent?.pubkey || ''),
|
||||
created_at: Number(signedEvent?.created_at || 0),
|
||||
kind: Number(signedEvent?.kind),
|
||||
content: String(signedEvent?.content || ''),
|
||||
tags: Array.isArray(signedEvent?.tags)
|
||||
? signedEvent.tags.map((tag) => Array.isArray(tag) ? tag.map((v) => String(v ?? '')) : [String(tag ?? '')])
|
||||
: [],
|
||||
sig
|
||||
};
|
||||
|
||||
this.signedEventsBySig.set(sig, snapshot);
|
||||
if (this.signedEventsBySig.size > 200) {
|
||||
const oldestKey = this.signedEventsBySig.keys().next().value;
|
||||
if (oldestKey) this.signedEventsBySig.delete(oldestKey);
|
||||
}
|
||||
|
||||
console.log('[Worker] MessageBasedSigner: Event signed');
|
||||
return signedEvent.sig;
|
||||
return sig;
|
||||
}
|
||||
|
||||
applySignedSnapshotToEvent(ndkEvent) {
|
||||
if (!ndkEvent) return false;
|
||||
|
||||
const sig = String(ndkEvent?.sig || '').trim();
|
||||
if (!sig) return false;
|
||||
|
||||
const snapshot = this.signedEventsBySig.get(sig);
|
||||
if (!snapshot) return false;
|
||||
|
||||
this.signedEventsBySig.delete(sig);
|
||||
|
||||
if (snapshot.id) ndkEvent.id = snapshot.id;
|
||||
if (snapshot.pubkey) ndkEvent.pubkey = snapshot.pubkey;
|
||||
if (Number.isFinite(snapshot.created_at) && snapshot.created_at > 0) ndkEvent.created_at = snapshot.created_at;
|
||||
if (Number.isFinite(snapshot.kind)) ndkEvent.kind = snapshot.kind;
|
||||
ndkEvent.content = String(snapshot.content || '');
|
||||
ndkEvent.tags = Array.isArray(snapshot.tags)
|
||||
? snapshot.tags.map((tag) => Array.isArray(tag) ? tag.map((v) => String(v ?? '')) : [String(tag ?? '')])
|
||||
: [];
|
||||
ndkEvent.sig = snapshot.sig;
|
||||
return true;
|
||||
}
|
||||
|
||||
async encrypt(recipient, plaintext) {
|
||||
@@ -1307,6 +1355,16 @@ class MessageBasedSigner {
|
||||
// Create message-based signer instance
|
||||
let messageSigner = null;
|
||||
|
||||
async function signEventWithMessageSigner(event, opts) {
|
||||
if (!event) throw new Error('Missing event to sign');
|
||||
await event.sign(messageSigner, opts);
|
||||
try {
|
||||
messageSigner?.applySignedSnapshotToEvent?.(event);
|
||||
} catch (error) {
|
||||
console.warn('[Worker] Failed applying signed snapshot to event:', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize NDK with Dexie cache and message-based signer
|
||||
async function initNDK() {
|
||||
console.log('[Worker] Initializing NDK...');
|
||||
@@ -1665,6 +1723,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);
|
||||
@@ -1679,6 +1782,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) {
|
||||
@@ -2126,6 +2235,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;
|
||||
|
||||
@@ -2142,20 +2304,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;
|
||||
|
||||
@@ -2662,7 +2813,7 @@ async function publishDirectProofs(debugContext = null) {
|
||||
});
|
||||
|
||||
log('sign 7375 start', { mintUrl });
|
||||
await tokenEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(tokenEvent);
|
||||
log('sign 7375 done', { mintUrl });
|
||||
|
||||
log('publish 7375 start', { mintUrl });
|
||||
@@ -2687,27 +2838,34 @@ async function publishDirectProofs(debugContext = null) {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await deletionEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(deletionEvent);
|
||||
await deletionEvent.publish();
|
||||
}
|
||||
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', {
|
||||
@@ -2726,7 +2884,7 @@ async function publishDirectProofs(debugContext = null) {
|
||||
});
|
||||
|
||||
log('sign 17375 start');
|
||||
await walletEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(walletEvent);
|
||||
log('sign 17375 done');
|
||||
|
||||
log('publish 17375 start');
|
||||
@@ -2750,7 +2908,7 @@ async function publishDirectProofs(debugContext = null) {
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await deletionEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(deletionEvent);
|
||||
await deletionEvent.publish();
|
||||
}
|
||||
log('delete old 17375 done');
|
||||
@@ -2951,7 +3109,7 @@ async function publishSpendingHistoryEvent(entry = {}) {
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
await historyEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(historyEvent);
|
||||
const relaySet = await historyEvent.publish();
|
||||
if (relaySet && relaySet.size > 0) {
|
||||
for (const relay of relaySet) {
|
||||
@@ -3003,7 +3161,7 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
|
||||
mintListEvent.mints = mints;
|
||||
mintListEvent.relays = relayUrls;
|
||||
if (p2pk) mintListEvent.p2pk = p2pk;
|
||||
await mintListEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(mintListEvent);
|
||||
const relaySet = await mintListEvent.publishReplaceable();
|
||||
eventId = mintListEvent.id || null;
|
||||
publishedRelayCount = relaySet ? relaySet.size : 0;
|
||||
@@ -3019,7 +3177,7 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await mintListEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(mintListEvent);
|
||||
const relaySet = await mintListEvent.publish();
|
||||
eventId = mintListEvent.id || null;
|
||||
publishedRelayCount = relaySet ? relaySet.size : 0;
|
||||
@@ -3233,7 +3391,7 @@ async function handleWalletSendNutzap(
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
await nutzapEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(nutzapEvent);
|
||||
|
||||
let relaySet = null;
|
||||
if (recipientRelayUrls.length > 0 && NDKRelaySet?.fromRelayUrls) {
|
||||
@@ -4155,6 +4313,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();
|
||||
@@ -4168,6 +4417,7 @@ function handleWalletShutdown(requestId, port) {
|
||||
directNutzapP2pk = null;
|
||||
directMintLocalUpdatedAt.clear();
|
||||
spendingHistoryLastFetchAtMs = 0;
|
||||
legacyWalletMigrationAttempted = false;
|
||||
spendingHistoryFetchPromise = null;
|
||||
broadcastWalletStatus();
|
||||
port.postMessage({ type: 'response', requestId, data: { success: true } });
|
||||
@@ -4256,7 +4506,7 @@ async function publishUserSettingsNow() {
|
||||
created_at: now
|
||||
});
|
||||
|
||||
await ndkEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(ndkEvent);
|
||||
const relaySet = await ndkEvent.publish();
|
||||
if (relaySet && relaySet.size > 0) {
|
||||
for (const relay of relaySet) {
|
||||
@@ -5304,7 +5554,7 @@ async function handlePublish(requestId, event, port) {
|
||||
|
||||
// Sign using message-based signer (will request signing from page)
|
||||
console.log('[Worker] Requesting signature from page...');
|
||||
await ndkEvent.sign(messageSigner);
|
||||
await signEventWithMessageSigner(ndkEvent);
|
||||
console.log('[Worker] Event signed, publishing to relays...');
|
||||
|
||||
// Publish to relays
|
||||
@@ -5884,6 +6134,7 @@ function handleDisconnect() {
|
||||
directNutzapP2pk = null;
|
||||
spendingHistoryLastFetchAtMs = 0;
|
||||
spendingHistoryFetchPromise = null;
|
||||
legacyWalletMigrationAttempted = false;
|
||||
}
|
||||
|
||||
// Get relay data for relays page
|
||||
@@ -6349,7 +6600,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);
|
||||
}
|
||||
|
||||
1266
www/post-feed.html
Normal file
1266
www/post-feed.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user