Compare commits

...

11 Commits

Author SHA1 Message Date
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
10 changed files with 5048 additions and 41 deletions

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

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

4454
www/document.html Normal file

File diff suppressed because it is too large Load Diff

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 = 'relay-events-db-6';
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();
};
@@ -319,6 +320,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 +337,7 @@ async function ensureAuthenticated() {
window.addEventListener('nlAuth', authHandler);
window.addEventListener('nlAuthRestored', authHandler);
window.addEventListener('nlMethodSelected', authHandler);
window.addEventListener('nlAuthStateChanged', authHandler);
});
}
@@ -484,6 +487,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 +541,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)) {

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.3",
"VERSION_NUMBER": "0.7.3",
"BUILD_DATE": "2026-04-19T17:43:13.562Z"
"VERSION": "v0.7.14",
"VERSION_NUMBER": "0.7.14",
"BUILD_DATE": "2026-05-27T19:48:34.590Z"
}

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

@@ -1367,7 +1367,6 @@
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 { SimplePlayer } from './js/greyscale-player.mjs';
@@ -4342,14 +4341,70 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
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 = ['HIGH', 'MP3_320', 'LOSSLESS'];
let lastError = null;
for (const quality of qualityCandidates) {
try {
console.debug('[music][download] requesting stream', { trackId: track.id, quality });
const stream = await musicApi.getTrackStream(track.id, quality);
const 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 } = {}) {

View File

@@ -6103,10 +6103,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 +6178,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);

View File

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

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