Compare commits

...

23 Commits

Author SHA1 Message Date
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
Laan Tungir
e943870d77 Port projects page functionality into template with public/no-login mode and project-native styling 2026-05-28 14:44:00 -04:00
Laan Tungir
08fc661077 Prioritize live window.nostr authState for active signing port registration 2026-05-27 15:48:34 -04:00
Laan Tungir
92430296c2 Fix shared-worker signer authority routing for multi-tab nsigner 2026-05-27 15:27:24 -04:00
Laan Tungir
bc8e2f46fd Improve shared worker nsigner routing with explicit active signing port registration 2026-05-27 15:17:45 -04:00
Laan Tungir
bd713d01f9 Fix stream site URL template editing and add NIP-53 starts/ends tags 2026-05-03 07:00:46 -04:00
Laan Tungir
7ce913997a This is a meaningful git commit message 2026-04-28 12:33:29 -04:00
Laan Tungir
63f5097a6d This is a meaningful git commit message 2026-04-28 10:44:22 -04:00
Laan Tungir
89fed0c818 This is a meaningful git commit message 2026-04-28 10:39:51 -04:00
Laan Tungir
0c9e8bebcb Implement document page chat binding, JSON editor flow, and resizable columns 2026-04-28 10:00:02 -04:00
Laan Tungir
22334ad432 . 2026-04-21 06:32:47 -04:00
Laan Tungir
3ff4bc532b This is a meaningful git commit message 2026-04-19 14:31:26 -04:00
Laan Tungir
dc5b97ecf4 Add music player auto-save toggle and Blossom upload for now-playing tracks 2026-04-19 14:00:32 -04:00
Laan Tungir
a2f7a7fceb Fix music optional auth to detect existing logged-in session on page load 2026-04-19 13:43:13 -04:00
Laan Tungir
9c85c8ec5e Vendor CDN scripts locally for FIPS HTTP compatibility 2026-04-19 13:37:32 -04:00
Laan Tungir
7f86ca8e66 This is a meaningful git commit message 2026-04-17 16:59:07 -04:00
64 changed files with 14448 additions and 4643 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": "..." }
]
}
```

201
plans/document-page.md Normal file
View File

@@ -0,0 +1,201 @@
# Document Page (`www/document.html`)
## Overview
A new page, derived from [`www/ai.html`](../www/ai.html:1), that lets the user co-author a long-form Nostr note with an LLM assistant. The page starts as an encrypted draft (**kind 30024**) and can be promoted to a published article (**kind 30023**).
Layout is a 3-column page:
```mermaid
flowchart LR
A[Documents + Skills<br/>left pane] --> B[Chat thread<br/>center pane]
B --> C[Document column<br/>title + markdown editor<br/>markdown/plaintext toggle]
C -.autosave 30024.-> R[(Nostr relays)]
B -.chat turn JSON.-> LLM[LLM]
LLM -.document + chat.-> B
LLM -.document.-> C
B -.autosave 30078 doc-chat:dtag.-> R
```
**There is no "Conversations" list.** The chat log is bound 1:1 to the document. Selecting a document loads its entire past chat; creating a new document creates a new empty chat.
## Approach summary (from Q&A)
| Topic | Decision |
|------|----------|
| LLM response format | **Option A**`response_format: {type: 'json_object'}` returning `{ "chat": "...", "document": "..." }`. LLM always returns the full document. |
| Skill strategy | **Option C (hybrid)** — a hardcoded page-level wrapper enforces the JSON contract; user-selected kind 31123 skills are concatenated *above* it as style/tone layers. |
| Document column behavior | **Editable** textarea (same feel as [`www/note.html`](../www/note.html:1)); both the user and the LLM can write to it. |
| View toggle | Reuse [`dot-menu`](../www/js/dot-menu.mjs:1): "View as markdown", "View as plaintext", "Copy document". |
## 1. HTML structure changes
Starting from the current copy of [`www/ai.html`](../www/ai.html:1) at [`www/document.html`](../www/document.html:1):
- Change `<title>` and header text from "AI" to "Document".
- Rename `#divAiLayout` to a 3-column flex row:
- Left: `#divDocumentsPane` (renamed from `#divAiConversationsPane`) — **Documents list on top, Skills on bottom**. The "Conversations" list is removed entirely.
- Center: `#divAiChatPane` (unchanged internals; just displays the chat log for the currently-selected document).
- **New right pane:** `#divAiDocumentPane` containing:
- Header row with title input, publish-as-30023 button, and a `.doc-menu-host` for the dot-menu.
- Summary input + hashtags input (single line each).
- Image URL input (optional).
- `#taDocument` markdown textarea (main editor).
- `#divDocumentPreview` hidden div for rendered-markdown view mode.
- Small status line ("Saved / UNSAVED", current kind, current `d` tag).
- Empty state: when no document is selected, the chat input is disabled and the document pane shows a placeholder "Select or create a document".
- CSS: `#divAiDocumentPane` is `flex: 1.2; min-width: 420px;` similar styling to the chat pane.
## 2. Document model (ported from note.html)
Port the following from [`www/note.html`](../www/note.html:552) but render the list in the **left column** (not the sidenav):
- `OBJ_NOTES` map keyed by `d` tag.
- `renderDocumentsList()` — renders `OBJ_NOTES` as a vertical list in `#divDocumentsList` (replaces `#divAiConversationsList`). Each row: title (or "Untitled"), small meta (`kind`, last-edited), delete button.
- `LoadNote(d)` — populates `#taDocument`, title/summary/hashtags/image inputs, sets `CURRENT_NOTE = d`, re-renders preview via `marked`, AND triggers `loadDocChat(d)` (see §5.3).
- `newDocument()` — assigns `CURRENT_NOTE = now()`, clears inputs and chat thread. No publish yet; publish happens on first real save.
- `DeleteNote(d)` — kind 5 delete event for the document. Also deletes the paired `doc-chat:<d>` kind-30078 event.
- `Publish30024Note()` — autosave path (unchanged logic).
- `Publish30023Note(d)` — promote draft → published (unchanged logic).
The sidenav drawer keeps only relay / blossom / AI-config sections — no notes table duplicated there.
## 3. Autosave
Mirror note.html's autosave:
- `LastText` baseline vs. current textarea value.
- Footer shows "UNSAVED" vs. "Saved".
- Debounced save on textarea input (e.g. 1500 ms idle). Immediate save when the LLM writes a new document. Immediate save on blur if dirty.
- On save, `Publish30024Note()` keeps the same `d` tag.
## 4. Markdown / plaintext / copy dot-menu
A tiny helper inside `document.html` (no new module needed) uses [`mountDotMenu`](../www/js/dot-menu.mjs:1) on `.doc-menu-host`:
```text
┌───────── Document ⋯ ─────────┐
│ View as markdown │
│ View as plaintext │
│ ────────────────────────────│
│ Copy document │
└──────────────────────────────┘
```
- **Plaintext** (default): show `#taDocument`, hide `#divDocumentPreview`.
- **Markdown**: hide textarea, show `#divDocumentPreview` with `marked.parse(taDocument.value)` (purified via `DOMPurify` already loaded by the page).
- **Copy document**: clipboard copy of the raw textarea value.
The current view mode lives in a `documentViewMode` variable (not per-message like messaging-ui — it's a single document). The pattern is copied from `setMessageViewMode` / `renderBubbleContent` in [`www/js/messaging-ui.mjs`](../www/js/messaging-ui.mjs:36).
## 5. LLM ↔ document wiring
### 5.1 Hardcoded skill wrapper
Add a constant inside `document.html`:
```js
const DOCUMENT_EDITOR_WRAPPER = `
system:
You are collaborating with the user on a single long-form markdown document.
Every reply MUST be valid JSON with exactly these fields:
{
"chat": "<short conversational reply, plain text>",
"document": "<the FULL updated markdown document, or null if unchanged>"
}
Rules:
- Never include commentary outside the JSON.
- Always return the ENTIRE document in "document" when you make ANY edit, even small ones.
- Set "document" to null (or omit it) when the user only asked a question and no edit is needed.
- Preserve the user's existing structure unless they ask you to restructure.
- Keep markdown formatting (headings, lists, code fences) intact.
user:
The current document is below, inside <document></document>. The user's new message follows.
<document>
{{document}}
</document>
User message:
{{message}}
`;
```
This wrapper is concatenated *after* any user-selected kind 31123 skills (so user skills set style/tone, wrapper enforces JSON contract last and therefore wins on conflicting instructions — per the existing "last skill wins" rule in [`plans/ai-skills-integration.md`](ai-skills-integration.md:5)).
### 5.2 Send flow changes
In the existing `sendPrompt()` path (the one already ported from ai.html):
1. Build `combinedSystem` from selected skills as today, then append `DOCUMENT_EDITOR_WRAPPER`'s system portion.
2. Resolve `{{document}}` placeholder with `taDocument.value` and `{{message}}` with the user's input.
3. Call the chat endpoint using [`sendAiChatJson`](../www/js/ai-chat.mjs:72) (already supports `response_format: { type: 'json_object' }`) — replaces the current plain `callOpenAICompatibleChat` path only on this page.
4. On success, `result.json` is `{ chat, document }`:
- Append `chat` as the assistant message in the thread.
- If `document` is a non-empty string and differs from `taDocument.value`, replace the textarea, re-render preview if in markdown view, trigger immediate autosave.
- If `document` is `null`/omitted, skip the document update (chat-only turn).
5. On malformed JSON, fall back to displaying `result.content` as the assistant message and do NOT touch the document. Show an inline warning chip under the message.
### 5.3 Chat storage — bound 1:1 to the document
There is **no separate Conversations concept**. The chat log for a document is persisted as its own addressable event:
| Field | Value |
|-------|-------|
| `kind` | `30078` |
| `d` tag | `doc-chat:<docDTag>` — e.g. `doc-chat:1714310000` |
| `t` tag | `client-document-chat-v1` (distinct from ai.html's `client-ai-chat-v1`) |
| `content` | NIP-44-encrypted JSON `{ schema: 1, messages: [...] }` |
Each message object shape:
```json
{
"id": "uuid-ish",
"role": "user" | "assistant",
"content": "text shown in bubble",
"documentSnapshot": "optional full markdown at time of assistant turn",
"created_at": 1714310015
}
```
Storing the `documentSnapshot` with the assistant message is optional but cheap and gives us a natural undo/rollback surface later.
**Load flow** (`loadDocChat(d)`):
1. Query cache for `{ kinds: [30078], authors: [self], '#d': ['doc-chat:' + d], '#t': ['client-document-chat-v1'] }`.
2. Decrypt `content` with NIP-44.
3. Parse `messages` array and render via the existing ai.html message thread renderer.
4. If no event exists, render empty thread.
**Save flow** (`saveDocChat(d, messages)`):
1. Encrypt `{ schema: 1, messages }` with NIP-44 to self.
2. Publish kind 30078 with tags `[['d', 'doc-chat:' + d], ['t', 'client-document-chat-v1']]`.
3. Because addressable, this replaces the prior version on relays.
**When messages are published**: after each completed assistant turn (chat appended + optional document replaced). User-only drafts in the input box are NOT published.
**Empty-to-first-message transition**: if `CURRENT_NOTE` is a freshly-created doc that has never been saved, sending the first user message runs `Publish30024Note()` first (so the document `d` exists on relays) and then `saveDocChat()`, preventing orphan chats.
**Deletion**: `DeleteNote(d)` publishes two kind-5 events — one for the document event id, one for the `doc-chat:<d>` event id.
## 6. Publish-as-30023 action
A "Publish as article" button in the document column header calls `Publish30023Note(CURRENT_NOTE)` (ported unchanged from note.html), with the same confirmation prompt. After publishing, the page reloads the sidenav list; the draft `d` tag is kept or cleaned up per note.html's existing logic.
## 7. Files touched
| File | Change |
|------|--------|
| [`www/document.html`](../www/document.html:1) | Major modification — new column, note model port, LLM JSON round-trip, dot-menu view toggle. |
| [`plans/document-page.md`](document-page.md:1) | This plan (new). |
No new JS modules are required. Everything reuses existing modules: [`dot-menu.mjs`](../www/js/dot-menu.mjs:1), [`ai-chat.mjs`](../www/js/ai-chat.mjs:1), [`init-ndk.mjs`](../www/js/init-ndk.mjs:1), `marked`, `DOMPurify`.
## 8. Open questions (non-blocking)
- Should we show a visible diff when the LLM rewrites the document (red/green lines) before accepting? — Deferred; v1 just overwrites and relies on Nostr history for rollback.
- Should very large documents be chunked into the prompt? — Deferred; rely on `max_tokens` and surface a truncation warning (`sendAiChatJson` already returns `truncated`).
- Should we debounce-autosave every keystroke or only on blur? — Start with 1500 ms idle + blur; revisit if relay spam shows up.

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?

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

@@ -0,0 +1,406 @@
# Unified Wallet Page (`wallet.html`) — Implementation Plan
## Overview
Create a new unified wallet page (`www/wallet.html`) that mirrors Amethyst's multi-rail wallet architecture, bringing together four payment rails into a single page:
1. **Bitcoin (Onchain / Taproot)** — NIP-BC onchain zaps, Taproot address display, onchain transaction history
2. **Cashu (NIP-60 ecash)** — Fix the existing kind 17375 content format bug, then migrate cashu.html functionality into the new page
3. **NWC (Nostr Wallet Connect / NIP-47)** — Lightning wallet connections via `nostr+walletconnect://` URIs
4. **CLINK Debit** — Spend-only debit pointers (`ndebit1...`) for CLINK protocol
This plan also fixes the critical NIP-60 compliance bug discovered during investigation: the current `ndk-worker.js` writes a proprietary JSON object as the kind 17375 encrypted content instead of the NIP-60 standard tag-array format, which makes Amethyst (and any standard NIP-60 client) unable to read the wallet's mints or privkey.
---
## Architecture
```mermaid
flowchart TD
subgraph Page["wallet.html (UI)"]
A[Header + Sidenav]
B[Bitcoin Onchain Section]
C[Cashu Wallet Section]
D[NWC Wallet List]
E[CLINK Debit List]
F[Add Wallet Modal]
end
subgraph Modules["JS Modules"]
G["wallet-ui.mjs - new orchestrator"]
H["cashu-wallet.mjs - existing, updated"]
I["nwc-wallet.mjs - new"]
J["onchain-wallet.mjs - new"]
K["clink-wallet.mjs - new"]
end
subgraph Worker["ndk-worker.js"]
L[NDK Core + Relays]
M[Direct Cashu proof store]
N["NWC request/response - new"]
O["Onchain zap events - new"]
end
subgraph NDK["ndk-core.bundle.js"]
P[NDKCashuWallet]
Q[NDKNWCWallet]
R[NDKEvent / NDKKind]
S[cashu-ts CashuWallet]
end
A --> G
B --> J
C --> H
D --> I
E --> K
F --> G
G --> H
G --> I
G --> J
G --> K
H --> M
I --> N
J --> O
K --> N
M --> L
N --> L
O --> L
H --> S
I --> Q
H --> P
J --> R
```
### File Structure
| File | Type | Purpose |
|------|------|---------|
| `www/wallet.html` | New | Page HTML + page-specific styles + inline module script |
| `www/js/wallet-ui.mjs` | New | Top-level orchestrator that renders all four rail sections |
| `www/js/cashu-wallet.mjs` | Existing, updated | Cashu controller — update to use NIP-60 tag-array format |
| `www/js/nwc-wallet.mjs` | New | NWC wallet list management, balance fetch, pay invoice |
| `www/js/onchain-wallet.mjs` | New | Bitcoin onchain section — Taproot address, onchain zap send/history |
| `www/js/clink-wallet.mjs` | New | CLINK debit pointer management, budget requests |
The module approach follows the existing pattern used by `cashu-wallet.mjs` and `post-interactions.mjs`.
---
## Phase 1: Fix NIP-60 Compliance Bug (Critical, do first)
### Problem
In [`www/ndk-worker.js`](www/ndk-worker.js:2758) lines 27582769, the worker hand-rolls the kind 17375 wallet event with a **proprietary JSON object** as the encrypted content:
```js
// CURRENT (wrong — proprietary)
const walletPayloadObj = {
mints: getDirectWalletMints(),
...(directNutzapPrivateKeyHex
? { nutzap: { privkey: directNutzapPrivateKeyHex, p2pk: directNutzapP2pk } }
: {})
};
```
NIP-60 requires the decrypted content to be a **JSON array of tag arrays** (the same format NDK's `payloadForEvent` and Amethyst's `CashuWalletEvent.build()` produce):
```json
[["mint","https://mint.example.com"],["privkey","<hex>"]]
```
### Fix
- [ ] **1.1** Change the wallet payload construction in `ndk-worker.js` to the NIP-60 tag-array format:
```js
// FIXED (NIP-60 standard)
const walletPayloadObj = [
...getDirectWalletMints().map((url) => ["mint", url]),
...(directNutzapPrivateKeyHex ? [["privkey", directNutzapPrivateKeyHex]] : [])
];
const walletPayload = JSON.stringify(walletPayloadObj);
```
- [ ] **1.2** Remove the non-standard `pubkey` tag from the public tags of the 17375 event (line 2755). The p2pk pubkey belongs on the kind 10019 `NutzapInfoEvent`, not on kind 17375. Amethyst reads the p2pk from the decrypted `privkey` tag, not from a public `pubkey` tag.
- [ ] **1.3** Update the wallet event **reading** path in `ndk-worker.js` (`hydrateNutzapKeyFromWalletEvents` and any other 17375 decryption) to parse the tag-array format instead of the object format. Add a backwards-compatibility shim: if the decrypted content parses as an object (old format), extract `mints` and `nutzap.privkey` from it; if it parses as an array, extract `mint` and `privkey` tags.
- [ ] **1.4** Add a one-time migration: when the worker detects an old-format 17375 event on startup, automatically republish it in the new tag-array format and NIP-09 delete the old one. This ensures existing users get upgraded transparently.
- [ ] **1.5** Test with Amethyst: after republishing, verify that Amethyst shows the wallet's mints and the "Could not read the existing wallet key" error no longer appears.
---
## Phase 2: Create `wallet.html` Page Shell
- [ ] **2.1** Create `www/wallet.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav with AI section, footer with relay status.
- [ ] **2.2** Add a sidenav nav entry to [`www/index.html`](www/index.html:543) — replace or augment the existing `cashu` entry with a `wallet` entry pointing to `wallet.html`.
- [ ] **2.3** Page layout — single-column centered (like `cashu.html` and `post.html`), with four stacked sections:
```
┌─────────────────────────────────────┐
│ HEADER (hamburger + "WALLET") │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ BITCOIN (Onchain) │ │
│ │ ₿ Taproot address │ │
│ │ Balance: 0 sats │ │
│ │ [Send Onchain Zap] [History]│ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ CASHU (NIP-60 ecash) │ │
│ │ Balance: 1,234 sats │ │
│ │ [Receive] [Send] [Deposit] │ │
│ │ [Withdraw] [Mints] [History]│ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ LIGHTNING (NWC) │ │
│ │ ┌─ Alby ──────── 500 sats ┐│ │
│ │ └──────────────────────────┘│ │
│ │ ┌─ Mutiny ────── 1,200 sats┐│ │
│ │ └──────────────────────────┘│ │
│ │ [+ Add NWC Connection] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ CLINK DEBIT │ │
│ │ ┌─ My Debit ─── spend-only ┐│ │
│ │ └──────────────────────────┘│ │
│ │ [+ Add CLINK Pointer] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ [+ ADD WALLET] │ │
│ │ Choose: Cashu | NWC | CLINK│ │
│ └─────────────────────────────┘ │
│ │
├─────────────────────────────────────┤
│ FOOTER (relay status + balance) │
└─────────────────────────────────────┘
```
- [ ] **2.4** Sidenav sections: keep the existing AI section, relay section. Move the Cashu mint-discovery and zap-settings sections from `cashu.html` into the `wallet.html` sidenav.
---
## Phase 3: Cashu Section (Migrate from cashu.html)
- [ ] **3.1** Port the Cashu UI from [`www/cashu.html`](www/cashu.html) into the Cashu section of `wallet.html` — balance card, action buttons (Receive/Send/Deposit/Withdraw), action panels, transaction history, mint management.
- [ ] **3.2** Reuse the existing [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) controller — it already wraps the worker wallet API. No changes needed to the controller itself (the fix is in the worker, Phase 1).
- [ ] **3.3** Port the mint-discovery sidebar and zap-settings sidebar from `cashu.html` into `wallet.html`'s sidenav.
- [ ] **3.4** Port the nutzap mint-list (kind 10019) publishing UI from `cashu.html`.
- [ ] **3.5** After `wallet.html` is complete, redirect `cashu.html` to `wallet.html` (or keep `cashu.html` as a thin redirect) and update the sidenav link in `index.html`.
---
## Phase 4: NWC (Nostr Wallet Connect) Section
NIP-47 (NWC) allows connecting to external Lightning wallets via `nostr+walletconnect://` URIs. The NDK bundle already includes [`NDKNWCWallet`](www/ndk-core.bundle.js:30422) which handles the NIP-47 protocol.
### Storage
NWC connection URIs are stored as a **kind 37550** application event (or in localStorage as a simpler approach initially). Each entry contains:
- Wallet name (user-defined label)
- NWC URI (`nostr+walletconnect://<pubkey>?relay=<relay>&secret=<secret>`)
- Whether it's the default wallet
### Tasks
- [ ] **4.1** Create `www/js/nwc-wallet.mjs` with:
- `loadNwcWallets()` — read stored NWC connections from localStorage (key: `nwcWallets`)
- `addNwcWallet(name, uri)` — parse and validate the URI, store it
- `removeNwcWallet(id)` — remove a connection
- `setDefaultNwcWallet(id)` — set the default wallet for zaps
- `fetchNwcBalance(walletId)` — use `NDKNWCWallet` to call `get_balance` via NIP-47
- `payInvoiceViaNwc(walletId, bolt11)` — use `NDKNWCWallet` to call `pay_invoice`
- `getNwcInfo(walletId)` — call `get_info` for wallet capabilities
- [ ] **4.2** Add NWC worker support in `ndk-worker.js`:
- New message handlers: `nwcAddWallet`, `nwcRemoveWallet`, `nwcGetBalance`, `nwcPayInvoice`, `nwcGetInfo`
- Use `NDKNWCWallet` from the bundle to make NIP-47 requests
- The NWC wallet sends kind 23194 events to the wallet service's relay and listens for responses
- [ ] **4.3** NWC UI in `wallet.html`:
- Wallet list — each row shows name, balance, default badge, delete button
- "Add NWC Connection" form — name input + URI input + paste/scan
- Wallet detail view — balance, transactions, send Lightning payment
- URI validation: must start with `nostr+walletconnect://` or `nostrwalletconnect://`
- [ ] **4.4** Integrate NWC as a zap payment source in [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs:1585) — when sending a zap, if the user has an NWC wallet configured, use it to pay the LN invoice automatically (replacing the current "open external wallet" fallback).
---
## Phase 5: Bitcoin Onchain Section
NIP-BC (Onchain Zaps) uses Taproot addresses derived from the user's Nostr pubkey to send on-chain Bitcoin zaps. Amethyst's [`OnchainSection.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt:91) shows the user's Taproot address, balance, and a send dialog.
### Tasks
- [ ] **5.1** Create `www/js/onchain-wallet.mjs` with:
- `getTaprootAddress(pubkey)` — derive the Taproot address from the user's Nostr pubkey (NIP-BC spec: tweak the pubkey with a standard script tree)
- `fetchOnchainBalance(address)` — query a block explorer API (mempool.space) for the address UTXO balance
- `fetchOnchainTransactions(address)` — query mempool.space for address transactions
- `sendOnchainZap(recipientPubkey, amountSats)` — build and broadcast an onchain zap (NIP-BC kind 8333 receipt event + Bitcoin tx)
- Subscribe to kind 8333 (`OnchainZapEvent`) for incoming/outgoing zap history
- [ ] **5.2** Onchain UI in `wallet.html`:
- Taproot address display with copy button and QR code
- Balance display (fetched from mempool.space)
- "Send Onchain Zap" button — opens a dialog to select recipient (by npub or address) and amount
- Transaction history list — incoming (green) and outgoing (orange) onchain zaps with counterparty info
- Public address warning (same as Amethyst: "This address is public — anyone can see your balance")
- [ ] **5.3** Add onchain zap event subscription in `ndk-worker.js`:
- Subscribe to kind 8333 events where `p` tag = user pubkey (incoming) and where author = user pubkey (outgoing)
- Broadcast onchain zap events to the page via `onchainZapReceived` / `onchainZapSent` messages
- [ ] **5.4** Integrate onchain zaps as a payment option in `post-interactions.mjs` — when zapping, offer the choice between Lightning (NWC), Cashu (nutzap), and Onchain (NIP-BC), similar to Amethyst's unified zap chip.
---
## Phase 6: CLINK Debit Section
CLINK (NIP-CLINK) is a spend-only debit protocol where the user adds a debit pointer (`ndebit1...`) that authorizes payments against a CLINK service. Amethyst's [`AddClinkDebitWalletScreen.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt:73) shows the add flow.
### Tasks
- [ ] **6.1** Create `www/js/clink-wallet.mjs` with:
- `loadClinkDebits()` — read stored CLINK debit pointers from localStorage (key: `clinkDebits`)
- `addClinkDebit(name, ndebitUri)` — parse the `ndebit1...` pointer, validate, store
- `removeClinkDebit(id)` — remove a debit pointer
- `requestClinkBudget(debitId, amountSats, frequency)` — request a spending budget from the CLINK service
- `payViaClinkDebit(debitId, bolt11)` — pay a Lightning invoice via the CLINK debit
- [ ] **6.2** CLINK UI in `wallet.html`:
- Debit list — each row shows name, "spend-only" badge, budget info, delete button
- "Add CLINK Debit" form — name input + `ndebit1...` pointer input
- Budget dialog — set spending limit (amount + frequency: one-time / daily / weekly / monthly)
- No balance display (CLINK debits are spend-only, no balance to show — same as Amethyst)
- [ ] **6.3** Integrate CLINK as a zap payment source in `post-interactions.mjs` — when the user has a CLINK debit configured, offer it as a payment option for Lightning zaps.
---
## Phase 7: Unified "Add Wallet" Flow
- [ ] **7.1** Add an "Add Wallet" button/card at the bottom of the wallet list that opens a modal with three choices (matching Amethyst's [`AddWalletScreen.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt:64)):
| Option | Icon | Description |
|--------|------|-------------|
| Cashu Wallet | Cashew nut | Create a NIP-60 ecash wallet with mints |
| NWC Connection | Lightning bolt | Connect a Lightning wallet via NIP-47 |
| CLINK Debit | Debit card | Add a spend-only CLINK debit pointer |
(Bitcoin onchain is always available — no "add" needed, the Taproot address is derived from the pubkey automatically.)
- [ ] **7.2** Each choice opens the corresponding add-form (reuse the forms built in Phases 36).
- [ ] **7.3** Default wallet selection — let the user set which wallet is the default for zaps (a star/radio button on each wallet card, matching Amethyst's `setDefaultWallet`).
---
## Phase 8: Unified Zap Routing
- [ ] **8.1** Update [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs:1585) zap handler to use a unified payment router that tries rails in priority order:
```mermaid
flowchart TD
A[User clicks Zap] --> B{Recipient has nutzap mint list?}
B -->|Yes| C{Sender has Cashu wallet with matching mint?}
C -->|Yes| D[Send Cashu Nutzap]
C -->|No| E{Sender has NWC or CLINK?}
B -->|No| E
E -->|NWC| F[Pay LN invoice via NWC]
E -->|CLINK| G[Pay LN invoice via CLINK]
E -->|None| H[Show invoice for external wallet]
F --> I{Recipient supports onchain zaps?}
G --> I
H --> I
I -->|Yes| J[Offer onchain zap alternative]
I -->|No| K[Done]
D --> K
J --> K
```
- [ ] **8.2** Add a zap method selector UI (like Amethyst's zap chip) that shows available rails for the current recipient and lets the user choose.
---
## Phase 9: Worker Updates
- [ ] **9.1** Add NWC message handlers to `ndk-worker.js` (Phase 4.2)
- [ ] **9.2** Add onchain zap subscription to `ndk-worker.js` (Phase 5.3)
- [ ] **9.3** Add CLINK payment request handler to `ndk-worker.js` (Phase 6.3)
- [ ] **9.4** Add `walletInit` expansion — the existing `walletInit` should also load NWC wallets and onchain state, not just Cashu
- [ ] **9.5** Add new worker-to-page events: `nwcBalanceUpdated`, `nwcPaymentResult`, `onchainZapReceived`, `onchainZapSent`, `clinkPaymentResult`
- [ ] **9.6** Update [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs:454) to dispatch the new worker events to the page
---
## Phase 10: Migration & Cleanup
- [ ] **10.1** Redirect `cashu.html``wallet.html` (replace content with a `<meta http-equiv="refresh">` redirect or a JS redirect)
- [ ] **10.2** Update sidenav in `index.html` — change `cashu` entry to `wallet`
- [ ] **10.3** Update any internal links that point to `cashu.html` (search all HTML files)
- [ ] **10.4** Update the footer balance display (currently in `relay-ui.mjs`) to show the unified wallet balance (Cashu + NWC + Onchain)
- [ ] **10.5** Keep `cashu-wallet.mjs` as-is (it's the Cashu controller, reused by `wallet.html`)
---
## NIP Compliance Reference
| Rail | NIP | Kind(s) | Status in this project |
|------|-----|---------|----------------------|
| Cashu Wallet | NIP-60 | 17375 (wallet), 7375 (token), 7376 (tx), 375 (backup) | **Bug: content format non-standard** — fixed in Phase 1 |
| Cashu Nutzap | NIP-61 | 10019 (mint list), 9321 (nutzap) | Working, but depends on 17375 fix for privkey |
| NWC | NIP-47 | 23194 (request/response) | **Not implemented** — Phase 4 |
| Onchain Zap | NIP-BC | 8333 (onchain zap receipt) | **Not implemented** — Phase 5 |
| CLINK Debit | NIP-CLINK | ndebit1 pointer | **Not implemented** — Phase 6 |
---
## Key Files to Modify
| File | Changes |
|------|---------|
| [`www/ndk-worker.js`](www/ndk-worker.js) | Fix 17375 content format (Phase 1), add NWC/onchain/CLINK handlers (Phases 4-6) |
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | Add new worker event dispatchers (Phase 9.6) |
| [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) | No changes (reused as-is) |
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Unified zap routing (Phase 8) |
| [`www/index.html`](www/index.html) | Sidenav entry update (Phase 10.2) |
## Key Files to Create
| File | Purpose |
|------|---------|
| `www/wallet.html` | New unified wallet page |
| `www/js/wallet-ui.mjs` | Page orchestrator |
| `www/js/nwc-wallet.mjs` | NWC wallet management |
| `www/js/onchain-wallet.mjs` | Bitcoin onchain section |
| `www/js/clink-wallet.mjs` | CLINK debit management |
---
## Implementation Order
The phases are ordered by dependency and impact:
1. **Phase 1** (NIP-60 fix) — do this first; it's a bug fix that benefits the existing `cashu.html` immediately
2. **Phase 2** (page shell) — creates the container for everything else
3. **Phase 3** (Cashu migration) — moves existing working functionality into the new page
4. **Phase 4** (NWC) — adds the first new rail (Lightning)
5. **Phase 5** (Onchain) — adds Bitcoin onchain zaps
6. **Phase 6** (CLINK) — adds CLINK debit support
7. **Phase 7** (Add wallet flow) — ties the rails together with a unified add modal
8. **Phase 8** (Unified zap routing) — integrates all rails into the zap flow
9. **Phase 9** (Worker updates) — can be done incrementally alongside Phases 4-6
10. **Phase 10** (Migration & cleanup) — final step after everything works

View File

@@ -20,10 +20,10 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<script src="./js/marked.min.js"></script>
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
<script src="./js/vendor/purify.min.js"></script>
<style>
#divBody {
@@ -799,7 +799,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -23,11 +23,11 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<script src="./js/marked.min.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
<script src="./js/vendor/purify.min.js"></script>
<style>
#divBody {
@@ -743,7 +743,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -10,7 +10,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<!-- Initialize theme BEFORE any components load -->
<script>
@@ -684,7 +684,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
//////////////////////////////////////////////////////////////////////

View File

@@ -19,7 +19,7 @@
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -301,7 +301,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -47,7 +47,7 @@
});
</script>
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -402,7 +402,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -466,7 +466,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, encryptContent, decryptContent } from './js/init-ndk.mjs';

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -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">
@@ -718,7 +719,7 @@
<script src="./nostr.bundle.js"></script>
<script src="./ndk-core.bundle.js?v=wallet-hotfix-3"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script type="module">
@@ -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

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
flex-direction: column !important;
@@ -210,7 +210,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -1,7 +1,7 @@
.msg-thread-pane {
flex: 1;
min-height: 0;
border: 2px solid var(--primary-color);
/* border: 2px solid var(--primary-color); */
border-radius: 10px;
background: var(--secondary-color);
display: flex;

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -378,7 +378,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -513,7 +513,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';

4454
www/document.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -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>
@@ -432,7 +433,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
@@ -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;

View File

@@ -22,7 +22,7 @@
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -139,7 +139,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -20,10 +20,10 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<script src="./js/marked.min.js"></script>
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
<script src="./js/vendor/purify.min.js"></script>
<style>
#divBody {
@@ -876,7 +876,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
@@ -363,7 +363,7 @@
================================================================ -->
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

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-5';
const WORKER_REVISION = 'nip60-privkey-ensure-1';
const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`;
// Initialize NDK SharedWorker (shared across all tabs/pages)
@@ -217,6 +217,7 @@ export async function initNDKPage(options = {}) {
const readyHandler = () => {
cleanup();
console.log('[init-ndk] Worker signaled ready, initialization complete');
tryRegisterActiveSigningPort();
resolve();
};
@@ -273,7 +274,8 @@ async function ensureAuthenticated() {
extension: true,
local: true,
nip46: true,
seedphrase: true
seedphrase: true,
nsigner: true
}
});
}
@@ -319,6 +321,7 @@ async function ensureAuthenticated() {
try {
const pubkey = await getPublicKeyWithTimeout(4000);
console.log('[init-ndk] Authentication successful, pubkey:', pubkey);
tryRegisterActiveSigningPort();
finish(true);
} catch (error) {
console.log('[init-ndk] window.nostr not ready yet:', error.message);
@@ -335,6 +338,7 @@ async function ensureAuthenticated() {
window.addEventListener('nlAuth', authHandler);
window.addEventListener('nlAuthRestored', authHandler);
window.addEventListener('nlMethodSelected', authHandler);
window.addEventListener('nlAuthStateChanged', authHandler);
});
}
@@ -484,6 +488,18 @@ function isExpectedSignerCapabilityError(method, error) {
/**
* Handle sign request from worker
*/
function tryRegisterActiveSigningPort() {
if (!ndkWorker?.port) return;
try {
const auth = window?.nostr?.authState || window?.NOSTR_LOGIN_LITE?.getAuthState?.() || null;
if (auth?.method === 'nsigner' && auth?.signer?.driver) {
ndkWorker.port.postMessage({ type: 'setActiveSigningPort' });
}
} catch (_) {
// ignore
}
}
async function handleSignRequest(requestId, method, params) {
try {
let result;
@@ -526,6 +542,9 @@ async function handleSignRequest(requestId, method, params) {
requestId: requestId,
result: result
});
// If this tab successfully signs using hardware signer, pin worker routing to this tab.
tryRegisterActiveSigningPort();
} catch (error) {
if (isExpectedSignerCapabilityError(method, error)) {
@@ -996,7 +1015,8 @@ export async function injectHeaderLoginButton() {
extension: true,
local: true,
nip46: true,
seedphrase: true
seedphrase: true,
nsigner: true
}
});
}
@@ -1405,6 +1425,10 @@ export function walletShutdown() {
return sendWorkerRequest('walletShutdown', {}, 10000, 'walletShutdown timeout');
}
export function walletRepublish() {
return sendWorkerRequest('walletRepublish', {}, 120000, 'walletRepublish timeout');
}
export function walletPublishMintList(relays = [], receiveMints = []) {
return sendWorkerRequest('walletPublishMintList', { relays, receiveMints }, 25000, 'walletPublishMintList timeout');
}

8
www/js/vendor/client-zip.esm.js vendored Normal file

File diff suppressed because one or more lines are too long

3
www/js/vendor/dash.all.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
www/js/vendor/hls.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
www/js/vendor/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
www/js/vendor/svg.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.1.0",
"VERSION_NUMBER": "0.1.0",
"BUILD_DATE": "2026-04-17T20:56:45.995Z"
"VERSION": "v0.7.23",
"VERSION_NUMBER": "0.7.23",
"BUILD_DATE": "2026-06-25T18:01:01.940Z"
}

View File

@@ -9,6 +9,7 @@ const STATS_POLL_INTERVAL = 10000;
const DEFAULT_STREAMING_SITE = Object.freeze({
name: 'laantungir.net',
streamBaseUrl: 'https://laantungir.net',
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
rtmpServer: 'rtmp://laantungir.net:1935/publish',
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
});
@@ -18,11 +19,13 @@ function normalizeStreamingSiteConfig(site) {
const input = site && typeof site === 'object' ? site : {};
const name = String(input.name || '').trim() || DEFAULT_STREAMING_SITE.name;
const streamBaseUrl = String(input.streamBaseUrl || '').trim() || DEFAULT_STREAMING_SITE.streamBaseUrl;
const streamUrlTemplate = String(input.streamUrlTemplate || '').trim() || DEFAULT_STREAMING_SITE.streamUrlTemplate;
const rtmpServer = String(input.rtmpServer || '').trim() || DEFAULT_STREAMING_SITE.rtmpServer;
const obsKeyTemplate = String(input.obsKeyTemplate || '').trim() || DEFAULT_STREAMING_SITE.obsKeyTemplate;
return {
name,
streamBaseUrl: streamBaseUrl.replace(/\/+$/, ''),
streamUrlTemplate,
rtmpServer,
obsKeyTemplate,
};
@@ -85,8 +88,12 @@ function deriveShowUrls(slug, siteConfig = DEFAULT_STREAMING_SITE) {
}
const base = site.streamBaseUrl;
const masterPlaylist = String(site.streamUrlTemplate || '').trim()
.replace(/\{base\}/gi, base)
.replace(/\{slug\}/gi, safeSlug)
|| `${base}/stream/${safeSlug}/stream.m3u8`;
return {
masterPlaylist: `${base}/stream/${safeSlug}/stream.m3u8`,
masterPlaylist,
viewerPage: `${base}/stream/${safeSlug}`,
stats: `${base}/api/stream/stats?show=${encodeURIComponent(safeSlug)}`,
obsServer: site.rtmpServer,
@@ -220,10 +227,11 @@ function parseStreamTargetFromUrl() {
}
function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, episodeId, episodeDescription, webUrl }) {
const safeStatus = String(status || 'planned').trim() || 'planned';
const tags = [
['d', dTag],
['title', String(title || '').trim() || 'Untitled stream'],
['status', String(status || 'planned').trim() || 'planned']
['status', safeStatus]
];
const safeSummary = String(summary || '').trim();
@@ -232,6 +240,7 @@ function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, ep
const safeEpisodeId = String(episodeId || '').trim();
const safeEpisodeDescription = String(episodeDescription || '').trim();
const safeWeb = String(webUrl || '').trim();
const nowUnix = String(Math.floor(Date.now() / 1000));
if (safeSummary) tags.push(['summary', safeSummary]);
if (safeImage) tags.push(['image', safeImage]);
@@ -240,6 +249,14 @@ function buildStreamTags({ dTag, title, summary, image, streamingUrl, status, ep
if (safeEpisodeDescription) tags.push(['episode_description', safeEpisodeDescription]);
if (safeWeb) tags.push(['web', safeWeb]);
// NIP-53 compatibility: include timestamps so clients don't default to Unix epoch.
// Prefer episodeId timestamp when available because it is set when the episode starts.
const startsAt = /^\d+$/.test(safeEpisodeId) ? safeEpisodeId : nowUnix;
tags.push(['starts', startsAt]);
if (safeStatus === 'ended') {
tags.push(['ends', nowUnix]);
}
return tags;
}
@@ -279,6 +296,7 @@ export function initVjStreamPanel({
streamingSiteFormPanel: document.getElementById('streamingSiteFormPanel'),
inputSiteName: document.getElementById('inputSiteName'),
inputSiteStreamBaseUrl: document.getElementById('inputSiteStreamBaseUrl'),
inputSiteStreamUrlTemplate: document.getElementById('inputSiteStreamUrlTemplate'),
inputSiteRtmpServer: document.getElementById('inputSiteRtmpServer'),
inputSiteObsKeyTemplate: document.getElementById('inputSiteObsKeyTemplate'),
btnSaveStreamingSite: document.getElementById('btnSaveStreamingSite'),
@@ -435,6 +453,9 @@ export function initVjStreamPanel({
if (els.inputSiteStreamBaseUrl) {
els.inputSiteStreamBaseUrl.value = String(seedSite?.streamBaseUrl || '').trim();
}
if (els.inputSiteStreamUrlTemplate) {
els.inputSiteStreamUrlTemplate.value = String(seedSite?.streamUrlTemplate || '').trim();
}
if (els.inputSiteRtmpServer) {
els.inputSiteRtmpServer.value = String(seedSite?.rtmpServer || '').trim();
}
@@ -450,7 +471,7 @@ export function initVjStreamPanel({
function upsertStreamingSite(draft, { mode = 'add', editingName = '' } = {}) {
const normalizedDraft = normalizeStreamingSiteConfig(draft);
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
if (!normalizedDraft.name || !normalizedDraft.streamBaseUrl || !normalizedDraft.streamUrlTemplate || !normalizedDraft.rtmpServer || !normalizedDraft.obsKeyTemplate) {
return false;
}
@@ -1780,6 +1801,7 @@ export function initVjStreamPanel({
showStreamingSiteForm('add', {
name: '',
streamBaseUrl: 'https://',
streamUrlTemplate: '{base}/stream/{slug}/stream.m3u8',
rtmpServer: 'rtmp://',
obsKeyTemplate: '{slug}/src/{SECRET_KEY}',
});
@@ -1807,6 +1829,7 @@ export function initVjStreamPanel({
const draft = normalizeStreamingSiteConfig({
name: String(els.inputSiteName?.value || '').trim(),
streamBaseUrl: String(els.inputSiteStreamBaseUrl?.value || '').trim(),
streamUrlTemplate: String(els.inputSiteStreamUrlTemplate?.value || '').trim(),
rtmpServer: String(els.inputSiteRtmpServer?.value || '').trim(),
obsKeyTemplate: String(els.inputSiteObsKeyTemplate?.value || '').trim(),
});

View File

@@ -18,7 +18,7 @@
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -241,7 +241,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -26,10 +26,10 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<script src="./js/marked.min.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
<script src="./js/vendor/purify.min.js"></script>
<style>
#divBody {
@@ -457,7 +457,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

5600
www/music-greyscale.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,8 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<script src="./js/vendor/dash.all.min.js"></script>
<style>
#divBody {
@@ -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>
@@ -1333,7 +1378,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
@@ -1367,18 +1412,18 @@
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 { 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 {
@@ -1467,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'),
@@ -1484,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,
@@ -1503,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';
@@ -1535,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';
@@ -1606,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;
@@ -1617,6 +1742,21 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
return message.includes('authentication required');
}
async function tryGetPubkeyWithoutPrompt({ attempts = 8, delayMs = 350 } = {}) {
for (let i = 0; i < attempts; i += 1) {
try {
const pubkey = await getPubkey();
if (pubkey) return pubkey;
} catch {
// Signer can attach slightly after initial script execution.
}
if (i < attempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
return '';
}
function isReadOnlyTargetMode() {
return Boolean(targetPubkey && (!currentPubkey || targetPubkey !== currentPubkey));
}
@@ -1673,6 +1813,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
if (musicEls.playlistNameInput) {
musicEls.playlistNameInput.disabled = isReadOnlyTargetMode();
}
updateUploadUiState();
}
async function initializeAuthentication(mode) {
@@ -1695,13 +1836,8 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
throw error;
}
try {
currentPubkey = await getPubkey();
isAuthenticated = Boolean(currentPubkey);
} catch {
currentPubkey = null;
isAuthenticated = false;
}
currentPubkey = await tryGetPubkeyWithoutPrompt();
isAuthenticated = Boolean(currentPubkey);
}
async function initializeAuthenticatedPageFeatures() {
@@ -1734,6 +1870,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
await initializeAuthenticatedPageFeatures();
syncLoggedInNpubToUrl();
updateViewerControls();
updateUploadUiState();
return true;
}
@@ -1830,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,
@@ -1838,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 || '',
};
}
@@ -2396,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);
@@ -2703,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,
@@ -3168,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;
}
@@ -3233,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' }
);
@@ -3292,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
@@ -4327,19 +4617,75 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
}
async function loadClientZipModule() {
return import('https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm');
return import('./js/vendor/client-zip.esm.js');
}
async function fetchDownloadBlobForTrack(track) {
if (!track?.id) throw new Error('Track not available for download.');
const stream = await musicApi.getTrackStream(track.id, 'LOSSLESS');
const streamUrl = stream?.streamUrl;
if (!streamUrl) throw new Error('No stream URL for download');
const res = await fetch(streamUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const ext = extensionFromMime(blob.type);
return { blob, ext };
console.debug('[music][download] start', {
trackId: track.id,
title: track.title || '',
});
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);
const streamUrl = stream?.streamUrl;
if (!streamUrl) {
lastError = new Error(`No stream URL for quality ${quality}`);
console.warn('[music][download] missing stream URL', { trackId: track.id, quality });
continue;
}
console.debug('[music][download] fetching stream URL', {
trackId: track.id,
quality,
streamUrl,
});
const res = await fetch(streamUrl);
if (!res.ok) {
lastError = new Error(`HTTP ${res.status} for quality ${quality}`);
console.warn('[music][download] fetch failed', {
trackId: track.id,
quality,
status: res.status,
statusText: res.statusText,
});
continue;
}
const blob = await res.blob();
const ext = extensionFromMime(blob.type);
console.debug('[music][download] success', {
trackId: track.id,
quality,
mimeType: blob.type,
size: blob.size,
ext,
});
return { blob, ext };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error || 'Unknown error'));
console.warn('[music][download] quality attempt threw', {
trackId: track.id,
quality,
error: lastError.message,
});
}
}
console.error('[music][download] all quality attempts failed', {
trackId: track.id,
error: lastError?.message || 'No stream URL for download',
});
throw (lastError || new Error('No stream URL for download'));
}
async function downloadTrackFile(track, { suppressStatus = false } = {}) {
@@ -4518,6 +4864,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
installImageFallbacks();
initMusicResizableColumns();
updateUploadUiState();
musicPlayer.onTrackChanged = (track) => {
updateMusicPlayerMeta(track);
@@ -4541,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]');
@@ -4767,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();
@@ -5060,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,30 @@ async function handleWalletCheckProofs(requestId, port) {
}
}
async function handleWalletRepublish(requestId, port) {
try {
if (!ndk || !currentPubkey) {
port.postMessage({ type: 'response', requestId, error: 'NDK not ready' });
return;
}
await ensureDirectWalletLoaded();
await publishDirectProofs({ traceId: 'walletRepublish' });
const payload = getWalletBalancePayload();
port.postMessage({
type: 'response',
requestId,
data: {
success: true,
mints: getDirectWalletMints(),
...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 +4350,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 +4439,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 +5487,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 +6067,7 @@ function handleDisconnect() {
directNutzapP2pk = null;
spendingHistoryLastFetchAtMs = 0;
spendingHistoryFetchPromise = null;
legacyWalletMigrationAttempted = false;
}
// Get relay data for relays page
@@ -6103,10 +6287,8 @@ self.onconnect = (event) => {
console.log('[Worker] New port connected');
connectedPorts.push(port);
activeSigningPort = port;
port.onmessage = async (e) => {
activeSigningPort = port;
const {
type,
pubkey,
@@ -6180,6 +6362,12 @@ self.onconnect = (event) => {
case 'ndkFetchEvents':
await handleNdkFetchEvents(requestId, filters, port);
break;
case 'setActiveSigningPort':
activeSigningPort = port;
console.log('[Worker] Active signing port set explicitly by page');
port.postMessage({ type: 'activeSigningPortSet' });
break;
case 'signResponse':
handleSignResponse(requestId, result, error);
@@ -6345,7 +6533,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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -253,6 +253,24 @@
#divHeaderText {
outline: none;
}
#txtDecryptTrace {
font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
}
#divDiagnosticBadge {
display: inline-block;
margin-left: 10px;
padding: 4px 8px;
border: 1px solid #b45309;
border-radius: 4px;
color: #b45309;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
</style>
<script src="./js/marked.min.js"></script>
</head>
@@ -330,10 +348,15 @@
<td class="tdTitle">Autosave</td>
<td id="txtAutosave" class="tdEditable" contenteditable="true">10</td>
</tr>
<tr>
<td class="tdTitle">Decrypt Trace</td>
<td id="txtDecryptTrace">None</td>
</tr>
<tr>
<td class="tdTitle"></td>
<td>
<button id="btnSaveNote" class="btn">Save Note</button>
<span id="divDiagnosticBadge" class="clsHidden">DIAGNOSTIC VIEW — do not save</span>
</td>
</tr>
</table>
@@ -413,7 +436,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
@@ -449,10 +472,13 @@ const versionInfo = await getVersion();
let OBJ_NOTES = {};
let CURRENT_NOTE = "";
let ENCRYPTED_TAG_TYPES = ["title", "image", "summary", "t"];
const MAX_DECRYPT_PASSES = 8;
let EDIT_MODE = false;
let LastText = "";
let NUM_LAST_EDIT_TIME = Math.floor(Date.now() / 1000);
let numAutoSaveSec = 30;
let CURRENT_DECRYPT_TRACE = null;
let DIAGNOSTIC_ACTIVE = false;
/* ================================================================
DOM VARIABLES
@@ -478,6 +504,8 @@ const versionInfo = await getVersion();
const divHeaderText = document.getElementById('divHeaderText');
const tblTags = document.getElementById('tblTags');
const divFiles = document.getElementById('divFiles');
const txtDecryptTrace = document.getElementById('txtDecryptTrace');
const divDiagnosticBadge = document.getElementById('divDiagnosticBadge');
/* ================================================================
HAMBURGER MENU
@@ -549,6 +577,195 @@ const versionInfo = await getVersion();
/* ================================================================
NOTE FUNCTIONS
================================================================ */
async function nip44DecryptFromSelf(ciphertext) {
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
if (!selfPubkey) throw new Error('Missing pubkey for NIP-44 decryption');
if (!window.nostr?.nip44?.decrypt) throw new Error('NIP-44 decrypt unavailable');
return await window.nostr.nip44.decrypt(selfPubkey, String(ciphertext || ''));
}
async function nip04DecryptFromSelf(ciphertext) {
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
if (!selfPubkey) throw new Error('Missing pubkey for NIP-04 decryption');
if (!window.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable');
return await window.nostr.nip04.decrypt(selfPubkey, String(ciphertext || ''));
}
function looksLikeNip04Ciphertext(input) {
const text = String(input || '').trim();
return /^[A-Za-z0-9+/_-]+={0,2}\?iv=[A-Za-z0-9+/_-]+={0,2}$/.test(text);
}
function looksLikeBase64(input) {
const text = String(input || '').trim();
if (!text || text.length < 24 || text.length % 4 !== 0) return false;
return /^[A-Za-z0-9+/]+={0,2}$/.test(text);
}
function looksLikeNip44Ciphertext(input) {
const text = String(input || '').trim();
if (!looksLikeBase64(text)) return false;
try {
const decoded = atob(text);
if (!decoded || decoded.length < 2) return false;
const versionByte = decoded.charCodeAt(0);
return versionByte === 1 || versionByte === 2;
} catch (_error) {
return false;
}
}
function classifyCiphertext(input) {
const text = String(input || '').trim();
if (!text) return 'plaintext';
if (looksLikeNip04Ciphertext(text)) return 'nip04';
if (looksLikeNip44Ciphertext(text)) return 'nip44';
if (/[\n\r\t]/.test(text) || /[#*_`\[\]{}()]/.test(text)) {
return 'plaintext';
}
if (looksLikeBase64(text)) return 'base64-unknown';
return 'plaintext';
}
async function decryptOnePass(input) {
const text = String(input || '').trim();
const kind = classifyCiphertext(text);
const methods = kind === 'nip04'
? ['nip04', 'nip44']
: ['nip44', 'nip04'];
for (const method of methods) {
try {
const output = method === 'nip44'
? await nip44DecryptFromSelf(text)
: await nip04DecryptFromSelf(text);
return {
ok: true,
method,
text: String(output || '')
};
} catch (_error) {
// try next method
}
}
return {
ok: false,
method: null,
text
};
}
async function peelDecrypt(input, maxPasses = MAX_DECRYPT_PASSES) {
let current = String(input || '').trim();
const trace = [];
let stoppedReason = 'max-passes';
for (let pass = 1; pass <= maxPasses; pass++) {
const beforeClass = classifyCiphertext(current);
if (beforeClass === 'plaintext') {
stoppedReason = 'plaintext';
break;
}
const one = await decryptOnePass(current);
if (!one.ok) {
stoppedReason = 'decrypt-failed';
break;
}
trace.push({
pass,
method: one.method,
inLength: current.length,
outLength: one.text.length
});
current = String(one.text || '');
}
return {
finalText: current,
trace,
stoppedReason,
finalClass: classifyCiphertext(current)
};
}
function formatDecryptTrace(diag) {
if (!diag) return 'None';
const passes = Number(diag.passes || 0);
const methods = Array.isArray(diag.methods) && diag.methods.length > 0
? diag.methods.join(' → ')
: 'none';
const reason = String(diag.stoppedReason || 'unknown');
const finalClass = String(diag.finalClass || 'unknown');
const finalLength = Number(diag.finalLength || 0);
return `passes=${passes} | methods=${methods} | stop=${reason} | final=${finalClass} | len=${finalLength}`;
}
async function peelTagsMaybeEncrypted(objTags) {
const safe = objTags && typeof objTags === 'object' ? { ...objTags } : {};
for (const key of ['title', 'image', 'summary']) {
const value = String(safe[key] || '').trim();
if (!value) continue;
const result = await peelDecrypt(value, 3);
safe[key] = result.trace.length > 0 ? result.finalText : value;
}
if (Array.isArray(safe.t)) {
const decryptedTags = [];
for (const tagValue of safe.t) {
const value = String(tagValue || '').trim();
if (!value) continue;
const result = await peelDecrypt(value, 3);
decryptedTags.push(result.trace.length > 0 ? result.finalText : value);
}
safe.t = decryptedTags;
}
return safe;
}
async function ensureNoteDiagnostics(note) {
if (!note || Number(note.kind) !== 30024) return note;
if (note.decryptDiagnostics) return note;
const peel = await peelDecrypt(String(note.content || ''));
const decryptedTags = await peelTagsMaybeEncrypted(note.objTags || {});
note.decryptedContent = peel.finalText;
note.decryptedObjTags = decryptedTags;
note.decryptDiagnostics = {
passes: peel.trace.length,
methods: peel.trace.map((step) => String(step.method || '').toUpperCase()),
stoppedReason: peel.stoppedReason,
finalClass: peel.finalClass,
finalLength: String(peel.finalText || '').length
};
return note;
}
function setDiagnosticUI(note) {
CURRENT_DECRYPT_TRACE = note?.decryptDiagnostics || null;
txtDecryptTrace.textContent = formatDecryptTrace(CURRENT_DECRYPT_TRACE);
DIAGNOSTIC_ACTIVE = Boolean(
Number(note?.kind) === 30024 &&
(Number(CURRENT_DECRYPT_TRACE?.passes || 0) > 0 || CURRENT_DECRYPT_TRACE?.stoppedReason === 'decrypt-failed')
);
if (DIAGNOSTIC_ACTIVE) {
divDiagnosticBadge.className = '';
} else {
divDiagnosticBadge.className = 'clsHidden';
}
}
const LoadSidenav = async () => {
// Subscribe to long-form notes (kind 30023 and 30024)
const notesSub = subscribe(
@@ -561,8 +778,8 @@ const versionInfo = await getVersion();
// Get notes from cache via events
let htmlOut = `<table id="tblNotes">`;
htmlOut += `<tr><th colspan="8"> NOTES </th></tr>`;
htmlOut += `<tr><th colspan="8"> &nbsp </th></tr>`;
htmlOut += `<tr><th colspan="10"> NOTES </th></tr>`;
htmlOut += `<tr><th colspan="10"> &nbsp </th></tr>`;
htmlOut += `<tr>
<th>Evt</th>
<th>Del</th>
@@ -573,21 +790,26 @@ const versionInfo = await getVersion();
<th>Edited</th>
<th>Type</th>
<th>Encrypted</th>
<th>Layers</th>
</tr>`;
// Build table from OBJ_NOTES
for (let Each of Object.keys(OBJ_NOTES).sort((a, b) => b - a)) {
const note = OBJ_NOTES[Each];
if (Number(note?.kind) === 30024) {
await ensureNoteDiagnostics(note);
}
let d = new Date(Number(Each) * 1000);
let dCreated = d.toLocaleString();
d = new Date(Number(OBJ_NOTES[Each].created_at) * 1000);
d = new Date(Number(note?.created_at) * 1000);
let dEdited = d.toLocaleString();
const dTagValue = OBJ_NOTES[Each].objTags?.d ?? `${OBJ_NOTES[Each].created_at} (from created_at)`;
const dTagValue = note?.objTags?.d ?? `${note?.created_at} (from created_at)`;
const rowTitle = note?.decryptedObjTags?.title || note?.objTags?.title || 'Untitled';
let Status = "Published";
let svgPub = `<div class="divTableButtons"></div>`;
if (OBJ_NOTES[Each].kind == 30024) {
Status = "Encrypted";
if (note?.kind == 30024) {
svgPub = `<div id="divSVGPublish-${Each}" class="divTableButtons">➜</div>`;
}
@@ -595,12 +817,13 @@ const versionInfo = await getVersion();
<td class="col5"> <div id="divSVG-${Each}" class="divTableButtons">📋</div> </td>
<td class="col5"> <div id="divSVGDelete-${Each}" class="divTableButtons">✕</div> </td>
<td class="col5"> ${svgPub} </td>
<td class="col0" id="divFileID-${Each}">${OBJ_NOTES[Each].objTags?.title || 'Untitled'}</td>
<td class="col0" id="divFileID-${Each}">${rowTitle}</td>
<td class="col1">${dTagValue}</td>
<td class="col1">${dCreated}</td>
<td class="col1">${dEdited}</td>
<td class="col3">${OBJ_NOTES[Each].kind} </td>
<td class="col3">${OBJ_NOTES[Each].encrypted ? 'Yes' : 'No'} </td>
<td class="col3">${note?.kind} </td>
<td class="col3">${note?.encrypted ? 'Yes' : 'No'} </td>
<td class="col3">${note?.kind == 30024 ? Number(note?.decryptDiagnostics?.passes || 0) : '-'}</td>
</tr>`;
}
@@ -656,9 +879,14 @@ const versionInfo = await getVersion();
// Save when switching from edit to view mode if there are unsaved changes
if (taNote.value.trim() !== LastText) {
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
await Publish30024Note();
LastText = taNote.value.trim();
if (DIAGNOSTIC_ACTIVE) {
console.log('[autosave] Diagnostic view active - skipping implicit save');
LastText = taNote.value.trim();
} else {
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
await Publish30024Note();
LastText = taNote.value.trim();
}
}
}
};
@@ -672,15 +900,24 @@ const versionInfo = await getVersion();
return;
}
taNote.value = OBJ_NOTES[id].content || '';
LastText = OBJ_NOTES[id].content || '';
txtTitle.textContent = OBJ_NOTES[id].objTags?.title || '';
divTitle.textContent = OBJ_NOTES[id].objTags?.title || '';
txtSummary.textContent = OBJ_NOTES[id].objTags?.summary || '';
txtTags.textContent = OBJ_NOTES[id].objTags?.t?.join(' ') || '';
const note = OBJ_NOTES[id];
await ensureNoteDiagnostics(note);
const displayTags = note.decryptedObjTags || note.objTags || {};
const displayContent = Number(note.kind) === 30024
? String(note.decryptedContent || note.content || '')
: String(note.content || '');
taNote.value = displayContent;
LastText = taNote.value.trim();
txtTitle.textContent = displayTags?.title || '';
divTitle.textContent = displayTags?.title || '';
txtSummary.textContent = displayTags?.summary || '';
txtTags.textContent = displayTags?.t?.join(' ') || '';
CURRENT_NOTE = id;
txtID.textContent = CURRENT_NOTE;
setDiagnosticUI(note);
// Parse markdown for preview
console.log('[note.html] LoadNote - window.marked exists:', !!window.marked);
@@ -699,11 +936,11 @@ const versionInfo = await getVersion();
console.log('[note.html] No content to display - reason:', reason);
}
if (OBJ_NOTES[id].objTags?.image === "" || !OBJ_NOTES[id].objTags?.image) {
if (displayTags?.image === "" || !displayTags?.image) {
txtImg.textContent = "";
imgMain.className = "clsHidden";
} else {
txtImg.textContent = OBJ_NOTES[id].objTags.image;
txtImg.textContent = displayTags.image;
imgMain.className = "clsVisible";
}
@@ -932,7 +1169,7 @@ const versionInfo = await getVersion();
);
// Listen for incoming note events
window.addEventListener('ndkEvent', (event) => {
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (evt.kind === 30023 || evt.kind === 30024) {
console.log("[note.html] Received note:", evt);
@@ -957,6 +1194,14 @@ const versionInfo = await getVersion();
objTags: objTags,
encrypted: evt.kind === 30024
};
if (evt.kind === 30024) {
await ensureNoteDiagnostics(OBJ_NOTES[dTag]);
}
if (isNavOpen) {
LoadSidenav();
}
}
});
@@ -982,6 +1227,7 @@ const versionInfo = await getVersion();
txtSummary.textContent = "";
txtTags.textContent = "";
divHeaderText.textContent = "TITLE";
setDiagnosticUI(null);
SetEditMode(true);
closeNav();
});
@@ -1042,6 +1288,11 @@ const versionInfo = await getVersion();
return;
}
if (DIAGNOSTIC_ACTIVE) {
console.log('[autosave] Skipping: diagnostic mode active');
return;
}
// Skip if not enough time passed since last edit
const secondsSinceEdit = Math.floor(Date.now() / 1000) - NUM_LAST_EDIT_TIME;
// console.log('[autosave] Seconds since edit:', secondsSinceEdit, '| required:', autoSaveSetting);

View File

@@ -22,7 +22,7 @@
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -356,7 +356,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divQRContainer {
@@ -161,7 +161,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script type="module">

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="../js/vendor/svg.min.js"></script>
</head>
<body>
@@ -145,7 +145,7 @@
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./ndk-core.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script type="module">

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="../js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -378,7 +378,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="../js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -301,7 +301,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getRelayData, fetchEventsFromAllRelays, ndkFetchEvents, queryCache } from './js/init-ndk.mjs';

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="../js/vendor/svg.min.js"></script>
</head>
<body>
@@ -169,7 +169,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="../js/vendor/svg.min.js"></script>
</head>
<body>
@@ -157,7 +157,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -22,11 +22,11 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="../js/vendor/svg.min.js"></script>
<script src="./js/marked.min.js"></script>
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
<script src="../js/vendor/purify.min.js"></script>
<style>
#divBody {
@@ -1015,7 +1015,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -173,8 +173,8 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="../js/vendor/svg.min.js"></script>
<script src="../js/vendor/hls.min.js"></script>
</head>
<body>
@@ -325,7 +325,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -172,8 +172,8 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="../js/vendor/svg.min.js"></script>
<script src="../js/vendor/hls.min.js"></script>
</head>
<body>
@@ -323,7 +323,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -19,7 +19,7 @@
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -253,7 +253,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -22,7 +22,7 @@
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -202,7 +202,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -21,7 +21,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -268,7 +268,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';

1399
www/projects.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@
});
</script>
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -514,7 +514,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
/* ================================================================
@@ -326,7 +326,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -20,7 +20,7 @@
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -639,7 +639,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -19,7 +19,7 @@
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
@@ -331,7 +331,7 @@
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {

View File

@@ -24,7 +24,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<!-- Strudel REPL Library (locally built from source) -->
<script type="module" src="./strudel-repl/index.js"></script>
@@ -255,7 +255,7 @@ note("<c a f e>(3,8)")
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
</head>
<body>
@@ -144,7 +144,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style>
/* ================================================================
@@ -305,7 +305,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -23,7 +23,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
@@ -508,7 +508,7 @@
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -175,7 +175,7 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="./js/vendor/svg.min.js"></script>
</head>
<body>
@@ -307,7 +307,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================

View File

@@ -26,9 +26,9 @@
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="./js/vendor/svg.min.js"></script>
<script src="./js/vendor/dash.all.min.js"></script>
<script src="./js/vendor/hls.min.js"></script>
<style>
#divBody {
@@ -1535,6 +1535,7 @@
<div id="streamingSiteFormPanel" class="hidden" style="margin-top:6px;">
<input id="inputSiteName" class="streamInput" placeholder="Site name" />
<input id="inputSiteStreamBaseUrl" class="streamInput" placeholder="Stream base URL (https://example.com)" />
<input id="inputSiteStreamUrlTemplate" class="streamInput" placeholder="Stream URL template ({base}/stream/{slug}/stream.m3u8)" />
<input id="inputSiteRtmpServer" class="streamInput" placeholder="RTMP server (rtmp://example.com:1935/publish)" />
<input id="inputSiteObsKeyTemplate" class="streamInput" placeholder="OBS key template ({slug}/src/{SECRET_KEY})" />
<div style="display:flex; gap:4px; flex-wrap:wrap; margin-top:4px;">
@@ -1905,7 +1906,7 @@
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
@@ -5609,7 +5610,7 @@
}
async function loadClientZipModule() {
return import('https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm');
return import('./js/vendor/client-zip.esm.js');
}
async function fetchDownloadBlobForTrack(track) {