Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92430296c2 | ||
|
|
bc8e2f46fd | ||
|
|
bd713d01f9 | ||
|
|
7ce913997a | ||
|
|
63f5097a6d |
@@ -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;
|
||||
|
||||
@@ -334,6 +334,59 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docPreviewMeta {
|
||||
border: 1px solid var(--muted-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: color-mix(in srgb, var(--secondary-color) 88%, var(--muted-color) 12%);
|
||||
}
|
||||
|
||||
.docPreviewMetaRow {
|
||||
font-size: 84%;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.docPreviewMetaLabel {
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.docPreviewImageWrap {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.docPreviewImage {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--muted-color);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docPreviewContent {
|
||||
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);
|
||||
@@ -1215,15 +1268,79 @@ user:
|
||||
return window.DOMPurify ? window.DOMPurify.sanitize(html) : html;
|
||||
}
|
||||
|
||||
function normalizePreviewImageUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim();
|
||||
if (!value) return '';
|
||||
try {
|
||||
const parsed = new URL(value, window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore invalid urls
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderDocumentMetaPreview() {
|
||||
const title = String(inpDocumentTitle?.value || '').trim();
|
||||
const summary = String(inpDocumentSummary?.value || '').trim();
|
||||
const tags = String(inpDocumentTags?.value || '').trim();
|
||||
const imageUrl = normalizePreviewImageUrl(inpDocumentImage?.value || '');
|
||||
const imageBlock = imageUrl
|
||||
? `<div class="docPreviewImageWrap"><img class="docPreviewImage" src="${escapeHtml(imageUrl)}" alt="Document image" loading="lazy" /></div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="docPreviewMeta">
|
||||
<div class="docPreviewMetaRow"><span class="docPreviewMetaLabel">Title:</span> ${escapeHtml(title || '—')}</div>
|
||||
<div class="docPreviewMetaRow"><span class="docPreviewMetaLabel">Summary:</span> ${escapeHtml(summary || '—')}</div>
|
||||
<div class="docPreviewMetaRow"><span class="docPreviewMetaLabel">Tags:</span> ${escapeHtml(tags || '—')}</div>
|
||||
${imageBlock}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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';
|
||||
divDocumentPreview.innerHTML = renderDocumentPreview(taDocument.value || '');
|
||||
|
||||
if (isMarkdown) {
|
||||
const metaHtml = renderDocumentMetaPreview();
|
||||
const bodyHtml = renderDocumentPreview(taDocument.value || '');
|
||||
divDocumentPreview.innerHTML = `${metaHtml}<div class="docPreviewContent">${bodyHtml}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const eventJson = JSON.stringify(buildEventViewJson(), null, 2);
|
||||
divDocumentPreview.innerHTML = `<pre class="docEventPre">${escapeHtml(eventJson)}</pre>`;
|
||||
return;
|
||||
}
|
||||
|
||||
taDocument.style.display = 'block';
|
||||
if (metaGridEl) metaGridEl.style.display = 'grid';
|
||||
divDocumentPreview.style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -1803,13 +1920,21 @@ user:
|
||||
}
|
||||
|
||||
function enforceDefaultSkillSelection() {
|
||||
const convo = getCurrentConversation();
|
||||
if (!convo) return;
|
||||
const defaultKey = getDefaultDocumentSkillKey();
|
||||
if (!defaultKey) return;
|
||||
const hasDefaultSkill = skills.some((s) => s.key === defaultKey);
|
||||
if (!hasDefaultSkill) return;
|
||||
|
||||
const convo = getCurrentConversation();
|
||||
if (!convo) {
|
||||
if (!selectedSkillKeys.includes(defaultKey)) {
|
||||
selectedSkillKeys = [...selectedSkillKeys, defaultKey];
|
||||
renderSkillsList();
|
||||
renderSkillsEditor();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = new Set(getConversationRemovedSkillKeys(convo));
|
||||
const selected = new Set(getConversationSkillKeys(convo));
|
||||
if (!removed.has(defaultKey) && !selected.has(defaultKey)) {
|
||||
@@ -3518,6 +3643,7 @@ user:
|
||||
inputEl.addEventListener('input', () => {
|
||||
if (CURRENT_NOTE) setDocumentInfo(`Editing draft • d:${CURRENT_NOTE} • UNSAVED`);
|
||||
scheduleDocumentAutosave();
|
||||
updateDocumentView();
|
||||
renderCurrentConversation();
|
||||
});
|
||||
inputEl.addEventListener('blur', () => {
|
||||
@@ -4061,15 +4187,40 @@ user:
|
||||
});
|
||||
}
|
||||
|
||||
function upsertDefaultDocumentSkillDraft() {
|
||||
if (!currentPubkey) return;
|
||||
const key = getDefaultDocumentSkillKey();
|
||||
if (!key) return;
|
||||
if (skills.some((s) => s.key === key)) return;
|
||||
|
||||
const llm = String(aiConfig.model || 'default').trim() || 'default';
|
||||
const temperature = Number(aiConfig.temperature ?? 0);
|
||||
const maxTokens = Math.max(1, Math.floor(Number(aiConfig.max_tokens ?? 10000)));
|
||||
const evt = {
|
||||
kind: 31123,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: currentPubkey,
|
||||
id: uid(),
|
||||
tags: [
|
||||
['d', DEFAULT_DOCUMENT_SKILL_SLUG],
|
||||
['description', DEFAULT_DOCUMENT_SKILL_DESCRIPTION],
|
||||
['llm', llm],
|
||||
['temperature', String(temperature)],
|
||||
['max_tokens', String(maxTokens)]
|
||||
],
|
||||
content: DEFAULT_DOCUMENT_SKILL_TEMPLATE
|
||||
};
|
||||
upsertSkillFromEvent(evt);
|
||||
}
|
||||
|
||||
async function ensureDefaultDocumentSkillExists() {
|
||||
if (!currentPubkey) return;
|
||||
const slug = DEFAULT_DOCUMENT_SKILL_SLUG;
|
||||
const key = getDefaultDocumentSkillKey();
|
||||
const existingInMemory = skills.find((s) => s.key === key);
|
||||
if (existingInMemory) {
|
||||
enforceDefaultSkillSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure visibility/editability immediately, even before network round-trips.
|
||||
upsertDefaultDocumentSkillDraft();
|
||||
enforceDefaultSkillSelection();
|
||||
|
||||
const filter = {
|
||||
kinds: [31123],
|
||||
@@ -4128,13 +4279,18 @@ user:
|
||||
};
|
||||
|
||||
const skillEvent = buildSkillEventFromEditor(draftSkill);
|
||||
await publishEvent(skillEvent);
|
||||
upsertSkillFromEvent({
|
||||
...skillEvent,
|
||||
pubkey: currentPubkey,
|
||||
id: uid()
|
||||
});
|
||||
setStatus('Created default-document-skill.');
|
||||
try {
|
||||
await publishEvent(skillEvent);
|
||||
upsertSkillFromEvent({
|
||||
...skillEvent,
|
||||
pubkey: currentPubkey,
|
||||
id: uid()
|
||||
});
|
||||
setStatus('Created default-document-skill.');
|
||||
} catch (error) {
|
||||
console.warn('[document.html] failed to publish default-document-skill, keeping local draft:', error);
|
||||
setStatus('Default document skill loaded locally (publish pending).');
|
||||
}
|
||||
enforceDefaultSkillSelection();
|
||||
}
|
||||
|
||||
@@ -4167,6 +4323,13 @@ user:
|
||||
updateDocumentView();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'View Event',
|
||||
onClick: () => {
|
||||
documentViewMode = 'event';
|
||||
updateDocumentView();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Copy document',
|
||||
onClick: async () => {
|
||||
|
||||
@@ -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_LOGIN_LITE?.getAuthState?.() || window?.nostr?.authState || 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)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.8",
|
||||
"VERSION_NUMBER": "0.7.8",
|
||||
"BUILD_DATE": "2026-04-28T14:39:51.733Z"
|
||||
"VERSION": "v0.7.13",
|
||||
"VERSION_NUMBER": "0.7.13",
|
||||
"BUILD_DATE": "2026-05-27T19:27:24.421Z"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
295
www/note.html
295
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;
|
||||
}
|
||||
</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">   </th></tr>`;
|
||||
htmlOut += `<tr><th colspan="10"> NOTES </th></tr>`;
|
||||
htmlOut += `<tr><th colspan="10">   </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);
|
||||
|
||||
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user