diff --git a/www/css/messaging-ui.css b/www/css/messaging-ui.css index 786f4df..0c80ced 100644 --- a/www/css/messaging-ui.css +++ b/www/css/messaging-ui.css @@ -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; diff --git a/www/document.html b/www/document.html index 1093a55..2afcc0a 100644 --- a/www/document.html +++ b/www/document.html @@ -372,6 +372,21 @@ min-height: 40px; } + .docEventPre { + margin: 0; + min-height: 40px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-family); + font-size: 84%; + line-height: 1.35; + border: 1px solid var(--muted-color); + border-radius: 8px; + padding: 10px; + background: color-mix(in srgb, var(--secondary-color) 90%, var(--muted-color) 10%); + } + #divDocumentInfo { font-size: 72%; color: var(--muted-color); @@ -1286,17 +1301,46 @@ user: `; } + function buildEventViewJson() { + const d = String(CURRENT_NOTE || '').trim(); + const note = d ? (OBJ_NOTES[d] || null) : null; + const fallbackKind = Number(note?.kind) === 30023 ? 30023 : 30024; + const tags = collectDocumentTags(); + return { + id: String(note?.id || ''), + pubkey: String(note?.pubkey || currentPubkey || ''), + kind: fallbackKind, + created_at: Number(note?.created_at || Math.floor(Date.now() / 1000)), + tags, + content: String(taDocument?.value || '') + }; + } + function updateDocumentView() { if (!taDocument || !divDocumentPreview) return; - if (documentViewMode === 'markdown') { + const metaGridEl = document.querySelector('#divAiDocumentPane .docPaneMetaGrid'); + const isMarkdown = documentViewMode === 'markdown'; + const isEvent = documentViewMode === 'event'; + + if (isMarkdown || isEvent) { taDocument.style.display = 'none'; + if (metaGridEl) metaGridEl.style.display = 'none'; divDocumentPreview.style.display = 'block'; - const metaHtml = renderDocumentMetaPreview(); - const bodyHtml = renderDocumentPreview(taDocument.value || ''); - divDocumentPreview.innerHTML = `${metaHtml}
${bodyHtml}
`; + + if (isMarkdown) { + const metaHtml = renderDocumentMetaPreview(); + const bodyHtml = renderDocumentPreview(taDocument.value || ''); + divDocumentPreview.innerHTML = `${metaHtml}
${bodyHtml}
`; + return; + } + + const eventJson = JSON.stringify(buildEventViewJson(), null, 2); + divDocumentPreview.innerHTML = `
${escapeHtml(eventJson)}
`; return; } + taDocument.style.display = 'block'; + if (metaGridEl) metaGridEl.style.display = 'grid'; divDocumentPreview.style.display = 'none'; } @@ -4279,6 +4323,13 @@ user: updateDocumentView(); } }, + { + label: 'View Event', + onClick: () => { + documentViewMode = 'event'; + updateDocumentView(); + } + }, { label: 'Copy document', onClick: async () => { diff --git a/www/js/version.json b/www/js/version.json index 9ca460c..c6d477b 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.10", - "VERSION_NUMBER": "0.7.10", - "BUILD_DATE": "2026-04-28T16:33:28.943Z" + "VERSION": "v0.7.11", + "VERSION_NUMBER": "0.7.11", + "BUILD_DATE": "2026-05-03T11:00:46.559Z" } diff --git a/www/js/vj-stream.mjs b/www/js/vj-stream.mjs index b938804..1e700a5 100644 --- a/www/js/vj-stream.mjs +++ b/www/js/vj-stream.mjs @@ -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(), }); diff --git a/www/note.html b/www/note.html index 9767482..299308b 100644 --- a/www/note.html +++ b/www/note.html @@ -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; + } @@ -330,10 +348,15 @@ Autosave 10 + + Decrypt Trace + None + + DIAGNOSTIC VIEW — do not save @@ -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 = ``; - htmlOut += ``; - htmlOut += ``; + htmlOut += ``; + htmlOut += ``; htmlOut += ` @@ -573,21 +790,26 @@ const versionInfo = await getVersion(); + `; // 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 = `
`; - if (OBJ_NOTES[Each].kind == 30024) { - Status = "Encrypted"; + if (note?.kind == 30024) { svgPub = `
`; } @@ -595,12 +817,13 @@ const versionInfo = await getVersion(); - + - - + + + `; } @@ -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); diff --git a/www/vj.html b/www/vj.html index 23178a1..52026e4 100644 --- a/www/vj.html +++ b/www/vj.html @@ -1535,6 +1535,7 @@
NOTES
 
NOTES
 
Evt DelEdited Type EncryptedLayers
📋
${svgPub} ${OBJ_NOTES[Each].objTags?.title || 'Untitled'}${rowTitle} ${dTagValue} ${dCreated} ${dEdited}${OBJ_NOTES[Each].kind} ${OBJ_NOTES[Each].encrypted ? 'Yes' : 'No'} ${note?.kind} ${note?.encrypted ? 'Yes' : 'No'} ${note?.kind == 30024 ? Number(note?.decryptDiagnostics?.passes || 0) : '-'}