Compare commits

...

10 Commits

Author SHA1 Message Date
Laan Tungir
073fb9e9af Feed upgrade Phase 1 & 2: simplified feed2.html + post-feed.html thread page
Phase 1 (feed2.html):
- Removed inline comment rendering and Comments toggle button
- Comment button now opens post-feed.html?event=<eventId> in new tab
- Removed inline reply composer (kept quote composer)
- Fixed missing queryCache dependency in initPostCards
- Fixed hardcoded color:red -> var(--primary-color)

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

View File

@@ -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 |

View File

@@ -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": "..." }
]
}
```

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

@@ -0,0 +1,272 @@
# 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 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.

View 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 ~13751380](../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
View File

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

View File

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

View File

@@ -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
View 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>

View File

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

542
www/js/gruuv-api.mjs Normal file
View 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
View 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
View 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,
};
}

View File

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

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.15",
"VERSION_NUMBER": "0.7.15",
"BUILD_DATE": "2026-05-28T18:44:00.780Z"
"VERSION": "v0.7.25",
"VERSION_NUMBER": "0.7.25",
"BUILD_DATE": "2026-06-25T23:45:36.529Z"
}

5600
www/music-greyscale.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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();

View File

@@ -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);
}

908
www/post-feed.html Normal file
View File

@@ -0,0 +1,908 @@
<!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>THREAD</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/post-composer.css" />
<link rel="stylesheet" href="./css/dot-menu.css" />
<!-- Initialize theme BEFORE any components load -->
<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" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<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;
}
#divBackBar,
#divHint,
#divThread,
#divComposer {
width: 80%;
min-width: 300px;
max-width: 700px;
}
#divBackBar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 5px;
}
#btnBack {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 90%;
color: var(--primary-color);
background: var(--secondary-color);
border: 1px solid var(--primary-color);
border-radius: 8px;
cursor: pointer;
text-decoration: none;
}
#btnBack:hover {
color: var(--accent-color);
border-color: var(--accent-color);
}
#divHint {
text-align: center;
color: var(--muted-color);
font-size: 80%;
margin-top: 5px;
}
#divThread {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
#divThreadHeader {
font-size: 90%;
color: var(--muted-color);
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
}
#divReplies {
display: flex;
flex-direction: column;
gap: 10px;
}
#divComposer {
margin-top: 5px;
margin-bottom: 20px;
}
#divComposer .post-composer-wrapper {
width: 100%;
}
.divPostItem .post-composer-wrapper {
width: 100%;
}
.threadError {
text-align: center;
padding: 40px 20px;
color: var(--muted-color);
font-size: 110%;
}
.threadError .threadErrorTitle {
font-size: 140%;
margin-bottom: 12px;
color: var(--primary-color);
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">THREAD</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divBackBar">
<a id="btnBack" href="./feed2.html" title="Back to feed">← Feed</a>
</div>
<div id="divHint">Loading thread…</div>
<div id="divThread">
<div id="divOriginalPost"></div>
<div id="divThreadHeader">Replies</div>
<div id="divReplies"></div>
</div>
<div id="divComposer"></div>
</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>
<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,
injectHeaderLoginButton,
subscribe,
disconnect,
getVersion,
updateVersionDisplay,
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';
/* ================================================================
URL PARAMETER PARSING
================================================================
Parse the `event` URL parameter. Accepts hex event ID, nevent,
or note (bech32). Decodes nevent/note to hex using nostr-tools
(window.NostrTools.nip19.decode), same pattern as post.html.
================================================================ */
const urlParams = new URLSearchParams(window.location.search);
const eventParamRaw = (urlParams.get('event') || '').trim();
function resolveEventParamToId(eventValue) {
const raw = String(eventValue || '').trim();
if (!raw) return null;
const normalized = raw.toLowerCase().startsWith('nostr:') ? raw.slice(6) : raw;
if (/^[0-9a-fA-F]{64}$/.test(normalized)) {
return normalized.toLowerCase();
}
try {
const decoded = window?.NostrTools?.nip19?.decode?.(normalized);
if (!decoded) return null;
if (decoded.type === 'note' && typeof decoded.data === 'string' && /^[0-9a-fA-F]{64}$/.test(decoded.data)) {
return decoded.data.toLowerCase();
}
if (decoded.type === 'nevent' && decoded.data?.id && /^[0-9a-fA-F]{64}$/.test(decoded.data.id)) {
return decoded.data.id.toLowerCase();
}
} catch (_) {}
return null;
}
function toNostrNoteRef(postId) {
try {
return window?.NostrTools?.nip19?.noteEncode
? window.NostrTools.nip19.noteEncode(postId)
: postId;
} catch (_) {
return postId;
}
}
/* ================================================================
GLOBAL STATE
================================================================ */
let currentPubkey = null;
let updateIntervalId = null;
let targetEventId = null;
let originalPost = null; // the kind 1 event being threaded
let originalPostEl = null; // DOM element for the original post
let replyComposerInstance = null;
const replies = []; // flat list of reply events
const replyIds = new Set(); // dedupe by event id
const renderedReplyIds = new Set();
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let isNavOpen = false;
let unsubscribeUserSettings = null;
let relayActivityListenersBound = false;
let authedPageInitialized = false;
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const divHint = document.getElementById('divHint');
const divOriginalPost = document.getElementById('divOriginalPost');
const divReplies = document.getElementById('divReplies');
const divThreadHeader = document.getElementById('divThreadHeader');
const divComposer = document.getElementById('divComposer');
const btnBack = document.getElementById('btnBack');
/* ================================================================
ERROR / EMPTY STATE RENDERING
================================================================ */
function renderErrorState(title, message) {
divOriginalPost.innerHTML = '';
divReplies.innerHTML = '';
divThreadHeader.style.display = 'none';
divComposer.style.display = 'none';
divHint.textContent = '';
divBody.insertAdjacentHTML(
'beforeend',
`<div class="threadError" style="width:80%;min-width:300px;max-width:700px;">
<div class="threadErrorTitle">${title}</div>
<div>${message}</div>
</div>`
);
}
/* ================================================================
INLINE COMPOSER (for replies on individual posts)
================================================================
Same pattern as feed2.html / post.html: per-post inline composer
used for quote replies. The bottom composer is the primary reply
entry point for the thread.
================================================================ */
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
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;
}
/* ================================================================
COMMENT / QUOTE INTENT HANDLERS
================================================================
- Comment button on the original post or a reply opens a new tab
to post-feed.html?event=<thatPostId> (same as feed2.html).
- Quote button opens an inline composer pre-filled with nostr:note.
================================================================ */
function handleCommentIntent({ postId }) {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
}
function handleQuoteIntent({ 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);
// Remove any replies by the muted author from the view.
for (let i = replies.length - 1; i >= 0; i -= 1) {
if (replies[i]?.pubkey === targetPubkey) {
replyIds.delete(replies[i]?.id);
replies.splice(i, 1);
}
}
renderReplies();
}
/* ================================================================
POST CARDS (post-interactions2.mjs)
================================================================ */
const cards = initPostCards({
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent: handleCommentIntent,
onQuoteIntent: handleQuoteIntent,
onMuteIntent: handleMuteIntent
});
/* ================================================================
HAMBURGER MENU
================================================================ */
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();
}
/* ================================================================
ORIGINAL POST RENDERING
================================================================ */
function renderOriginalPost(evt) {
if (!evt?.id) return;
originalPost = evt;
divOriginalPost.innerHTML = '';
if (isEventMuted(evt)) {
divOriginalPost.innerHTML = '<div class="threadError"><div class="threadErrorTitle">Muted post</div><div>This post is from a muted author.</div></div>';
return;
}
originalPostEl = cards.createCard(evt, { currentPubkey, autoWire: false, autoplayVideo: false });
divOriginalPost.appendChild(originalPostEl);
cards.wireInteractions([evt.id], { currentPubkey });
divHint.textContent = `Thread for ${evt.id.slice(0, 8)}`;
divFooterRight.textContent = '1 post';
}
/* ================================================================
REPLY THREAD RENDERING (flat, chronological ascending)
================================================================ */
function isReplyToThread(evt) {
if (!evt?.tags) return false;
const eTags = evt.tags.filter((t) => t[0] === 'e' && t[1]);
if (eTags.length === 0) return false;
// A reply is any kind 1 that references the target event id in an e tag.
return eTags.some((t) => t[1] === targetEventId);
}
function upsertReply(evt) {
if (!evt?.id || replyIds.has(evt.id)) return false;
if (evt.id === targetEventId) return false;
if (isEventMuted(evt)) return false;
if (!isReplyToThread(evt)) return false;
replyIds.add(evt.id);
replies.push(evt);
return true;
}
function renderReplies() {
replies.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
// Re-render the whole reply list (flat thread, simplest & most reliable).
divReplies.innerHTML = '';
renderedReplyIds.clear();
if (replies.length === 0) {
divThreadHeader.textContent = 'No replies yet';
divFooterRight.textContent = originalPost ? '1 post' : '0 posts';
return;
}
divThreadHeader.textContent = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
replies.forEach((reply) => {
const replyEl = cards.createCard(reply, { currentPubkey, autoWire: false, autoplayVideo: false });
divReplies.appendChild(replyEl);
renderedReplyIds.add(reply.id);
});
cards.wireInteractions(replies.map((r) => r.id), { currentPubkey });
divFooterRight.textContent = `${1 + replies.length} posts`;
}
function appendReplyIfNew(evt) {
if (!upsertReply(evt)) return;
renderReplies();
}
/* ================================================================
BOTTOM REPLY COMPOSER
================================================================
Tags the original post (['e', postId, '', 'reply']) and the
original author (['p', postPubkey]) per NIP-10 reply convention.
================================================================ */
async function mountReplyComposer() {
if (!divComposer) return;
divComposer.innerHTML = '';
divComposer.style.display = '';
const hostEl = document.createElement('div');
hostEl.className = 'topPostComposerInput';
divComposer.appendChild(hostEl);
const focusReplyComposer = () => {
requestAnimationFrame(() => {
hostEl.focus();
const selection = window.getSelection?.();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(hostEl);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
});
};
replyComposerInstance = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: false,
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return;
if (!originalPost?.id) return;
const tags = [
['e', originalPost.id, '', 'reply'],
['p', originalPost.pubkey]
];
await publishEvent({
kind: 1,
content: text,
tags,
created_at: Math.floor(Date.now() / 1000)
});
replyComposerInstance?.clear?.();
focusReplyComposer();
}
});
focusReplyComposer();
}
/* ================================================================
FETCH ORIGINAL POST + REPLIES
================================================================ */
async function fetchOriginalPost() {
// Cache first for fast display, then hydrate from relays.
let matched = null;
try {
const cached = await queryCache({ ids: [targetEventId], limit: 1 });
matched = Array.isArray(cached)
? cached.find((evt) => evt?.id === targetEventId) || cached[0]
: null;
} catch (_) {}
if (matched) {
renderOriginalPost(matched);
await mountReplyComposer();
await fetchReplies();
} else {
divHint.textContent = 'Loading event…';
}
// Relay hydration.
void ndkFetchEvents({ ids: [targetEventId], limit: 1 }).then((relayEvents) => {
const relayMatch = Array.isArray(relayEvents)
? relayEvents.find((evt) => evt?.id === targetEventId) || relayEvents[0]
: null;
if (relayMatch) {
if (!originalPost) {
renderOriginalPost(relayMatch);
mountReplyComposer();
fetchReplies();
} else {
// Update in place if a fresher copy arrives.
renderOriginalPost(relayMatch);
}
return;
}
if (!matched) {
renderErrorState('Post not found', 'Could not find this post on your relays. It may have been deleted or your relays do not have it.');
}
}).catch((error) => {
console.warn('[post-feed.html] Event relay hydration failed:', error?.message || error);
if (!matched) {
renderErrorState('Post not found', 'Could not find this post on your relays.');
}
});
}
async function fetchReplies() {
if (!targetEventId) return;
// Cache first.
try {
const cachedReplies = await queryCache({ kinds: [1], '#e': [targetEventId] });
if (Array.isArray(cachedReplies)) {
cachedReplies.forEach((evt) => upsertReply(evt));
renderReplies();
}
} catch (_) {}
// Then relay fetch.
void ndkFetchEvents({ kinds: [1], '#e': [targetEventId] }).then((fresh) => {
if (Array.isArray(fresh)) {
fresh.forEach((evt) => upsertReply(evt));
}
renderReplies();
}).catch((error) => {
console.warn('[post-feed.html] Reply fetch failed:', error?.message || error);
});
}
/* ================================================================
REAL-TIME SUBSCRIPTION
================================================================
Subscribe to the original post (in case it updates) and to new
replies (kind 1 with an e tag pointing at the post id).
================================================================ */
function startRealtimeSubscriptions() {
if (!targetEventId) return;
const liveSince = Math.floor(Date.now() / 1000) - (30 * 24 * 3600);
// Subscribe to the original post itself.
subscribe(
{ kinds: [1], ids: [targetEventId], since: liveSince },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
// Subscribe to replies referencing this post.
subscribe(
{ kinds: [1], '#e': [targetEventId] },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
}
/* ================================================================
AUTH / PAGE INIT
================================================================ */
async function initializeAuthenticatedPageFeatures() {
if (!currentPubkey) {
await injectHeaderLoginButton();
return;
}
if (authedPageInitialized) return;
await injectHeaderAvatar(currentPubkey);
console.log('[post-feed.html] Authenticated as:', currentPubkey);
try {
await getUserSettings();
} catch (error) {
console.warn('[post-feed.html] getUserSettings failed:', error);
}
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings(() => {
// Settings updated; no page-specific settings to re-render yet.
});
}
await ensureMuteListLoaded();
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
const updateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
} catch (error) {
console.error('[post-feed.html] Error updating footer:', error);
}
};
await updateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(updateFooter, 1000);
}
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail;
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
setRelayActivityState(relayUrl, activity);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
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);
}
/* ================================================================
MAIN INITIALIZATION
================================================================ */
(async function main() {
try {
const versionInfo = await getVersion();
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
console.log(`[post-feed.html ${versionInfo.VERSION}] Loading...`);
// Validate the event parameter before anything else.
targetEventId = resolveEventParamToId(eventParamRaw);
if (!targetEventId) {
if (!eventParamRaw) {
renderErrorState('Missing post', 'No event parameter provided. Use <code>post-feed.html?event=<id></code>.');
} else {
renderErrorState('Invalid event', `Could not decode event parameter: <code>${eventParamRaw}</code>`);
}
return;
}
// Hamburger + version bar buttons.
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);
// Auth — required mode (we need the worker to fetch events).
await initNDKPage();
currentPubkey = await getPubkey();
await initializeAuthenticatedPageFeatures();
// Listen for live events from the worker.
window.addEventListener('ndkEvent', (e) => {
const evt = e.detail;
if (!evt || evt.kind !== 1) return;
if (evt.id === targetEventId) {
if (!originalPost) {
renderOriginalPost(evt);
mountReplyComposer();
fetchReplies();
}
return;
}
if (isReplyToThread(evt)) {
appendReplyIfNew(evt);
}
});
// Fetch the post + replies, then start realtime subscriptions.
await fetchOriginalPost();
startRealtimeSubscriptions();
await updateVersionDisplay();
console.log('[post-feed.html] Initialization complete');
} catch (error) {
console.error('[post-feed.html] Initialization failed:', error);
renderErrorState('Error', error?.message || String(error));
}
})();
</script>
</body>
</html>

View File

@@ -637,6 +637,7 @@
};
let projectsPageInitialized = false;
let sortProjectsBy30DayDesc = null;
function setProjectsStatus(msg, isError = false) {
if (!statusEl) return;
@@ -998,6 +999,9 @@
try {
await Promise.all(Array.from({ length: Math.min(concurrency, total || 1) }, () => worker()));
if (typeof sortProjectsBy30DayDesc === 'function') {
sortProjectsBy30DayDesc();
}
} finally {
stopSpinner();
}
@@ -1061,7 +1065,8 @@
});
});
sortRowsByColumn(3, false);
sortProjectsBy30DayDesc = () => sortRowsByColumn(3, false);
sortProjectsBy30DayDesc();
}
async function initializeProjectsPage() {