diff --git a/src/lib/components/CommentBox.svelte b/src/lib/components/CommentBox.svelte index 15fab77..fb91e11 100644 --- a/src/lib/components/CommentBox.svelte +++ b/src/lib/components/CommentBox.svelte @@ -6,6 +6,7 @@ getEventHash, signEvent, getUserMetadata, + prefixNostrAddresses, type NostrProfile, } from "$lib/utils/nostrUtils"; import { standardRelays, fallbackRelays } from "$lib/consts"; @@ -29,17 +30,22 @@ let showOtherRelays = $state(false); let showFallbackRelays = $state(false); let userProfile = $state(null); - let pubkey = $state(null); + let pubkey = $derived(() => get(activePubkey)); + $effect(() => { - pubkey = get(activePubkey); + if (!pubkey()) { + userProfile = null; + error = null; + } }); - // Fetch user profile on mount - onMount(() => { - const trimmedPubkey = pubkey?.trim(); + // Remove the onMount block that sets pubkey and userProfile only once. Instead, fetch userProfile reactively when pubkey changes. + $effect(() => { + const trimmedPubkey = pubkey()?.trim(); if (trimmedPubkey && /^[a-fA-F0-9]{64}$/.test(trimmedPubkey)) { + const npub = nip19.npubEncode(trimmedPubkey); + // Call an async function, but don't make the effect itself async (async () => { - const npub = nip19.npubEncode(trimmedPubkey); userProfile = await getUserMetadata(npub); error = null; })(); @@ -52,6 +58,13 @@ } }); + $effect(() => { + if (success) { + content = ''; + preview = ''; + } + }); + // Markup buttons const markupButtons = [ { label: "Bold", action: () => insertMarkup("**", "**") }, @@ -141,16 +154,17 @@ success = null; try { - if (!pubkey || !/^[a-fA-F0-9]{64}$/.test(pubkey)) { + const pk = pubkey() || ''; + if (!pk || !/^[a-fA-F0-9]{64}$/.test(pk)) { throw new Error('Invalid public key: must be a 64-character hex string.'); } if (props.event.kind === undefined || props.event.kind === null) { throw new Error('Invalid event: missing kind'); } - // Always use kind 1111 for comments - const kind = 1111; const parent = props.event; + // Use the same kind as parent for replies, or 1111 for generic replies + const kind = parent.kind === 1 ? 1 : 1111; // Try to extract root info from parent tags (NIP-22 threading) let rootKind = parent.kind; let rootPubkey = getPubkeyString(parent.pubkey); @@ -161,9 +175,18 @@ let parentAddress = ''; let parentKind = parent.kind; let parentPubkey = getPubkeyString(parent.pubkey); - // Try to find root event info from tags (E/A/I) + + // Check if parent is a replaceable event (3xxxxx kinds) + const isParentReplaceable = parentKind >= 30000 && parentKind < 40000; + + // Check if parent is a comment (kind 1111) - if so, we need to find the original root + const isParentComment = parentKind === 1111; + + // Try to find root event info from parent tags (E/A/I) let isRootA = false; let isRootI = false; + let rootIValue = ''; + let rootIRelay = ''; if (parent.tags) { const rootE = parent.tags.find((t: string[]) => t[0] === 'E'); const rootA = parent.tags.find((t: string[]) => t[0] === 'A'); @@ -181,36 +204,134 @@ rootPubkey = getPubkeyString(parent.tags.find((t: string[]) => t[0] === 'P')?.[1] || rootPubkey); rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind; } else if (rootI) { - rootAddress = rootI[1]; + rootIValue = rootI[1]; + rootIRelay = getRelayString(rootI[2]); rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind; } } - // Compose tags according to NIP-22 + + // Compose tags according to event kind const tags: string[][] = []; - // Root scope (uppercase) - if (rootAddress) { - tags.push([isRootA ? 'A' : isRootI ? 'I' : 'E', rootAddress || rootId, rootRelay, rootPubkey]); - } else { - tags.push(['E', rootId, rootRelay, rootPubkey]); - } - tags.push(['K', String(rootKind), '', '']); - tags.push(['P', rootPubkey, rootRelay, '']); - // Parent (lowercase) - if (parentAddress) { - tags.push([isRootA ? 'a' : isRootI ? 'i' : 'e', parentAddress || parent.id, parentRelay, parentPubkey]); + + if (kind === 1) { + // Kind 1 replies use simple e/p tags, not NIP-22 threading + tags.push(['e', parent.id, parentRelay, 'root']); + tags.push(['p', parentPubkey]); + + // If parent is replaceable, also add the address + if (isParentReplaceable) { + const dTag = parent.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''; + if (dTag) { + const parentAddress = `${parentKind}:${parentPubkey}:${dTag}`; + tags.push(['a', parentAddress, '', 'root']); + } + } } else { - tags.push(['e', parent.id, parentRelay, parentPubkey]); + // Kind 1111 uses NIP-22 threading format + // For replaceable events, use A/a tags; for regular events, use E/e tags + if (isParentReplaceable) { + // For replaceable events, construct the address: kind:pubkey:d-tag + const dTag = parent.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''; + if (dTag) { + const parentAddress = `${parentKind}:${parentPubkey}:${dTag}`; + + // If we're replying to a comment, use the root from the comment's tags + if (isParentComment && rootId !== parent.id) { + // Root scope (uppercase) - use the original article + tags.push(['A', parentAddress, parentRelay]); + tags.push(['K', String(rootKind)]); + tags.push(['P', rootPubkey, rootRelay]); + // Parent scope (lowercase) - the comment we're replying to + tags.push(['e', parent.id, parentRelay]); + tags.push(['k', String(parentKind)]); + tags.push(['p', parentPubkey, parentRelay]); + } else { + // Top-level comment - root and parent are the same + tags.push(['A', parentAddress, parentRelay]); + tags.push(['K', String(rootKind)]); + tags.push(['P', rootPubkey, rootRelay]); + tags.push(['a', parentAddress, parentRelay]); + tags.push(['e', parent.id, parentRelay]); + tags.push(['k', String(parentKind)]); + tags.push(['p', parentPubkey, parentRelay]); + } + } else { + // Fallback to E/e tags if no d-tag found + if (isParentComment && rootId !== parent.id) { + tags.push(['E', rootId, rootRelay]); + tags.push(['K', String(rootKind)]); + tags.push(['P', rootPubkey, rootRelay]); + tags.push(['e', parent.id, parentRelay]); + tags.push(['k', String(parentKind)]); + tags.push(['p', parentPubkey, parentRelay]); + } else { + tags.push(['E', parent.id, parentRelay]); + tags.push(['K', String(rootKind)]); + tags.push(['P', rootPubkey, rootRelay]); + tags.push(['e', parent.id, parentRelay]); + tags.push(['k', String(parentKind)]); + tags.push(['p', parentPubkey, parentRelay]); + } + } + } else { + // For regular events, use E/e tags + if (isParentComment && rootId !== parent.id) { + // Reply to a comment - distinguish root from parent + if (rootAddress) { + tags.push([isRootA ? 'A' : isRootI ? 'I' : 'E', rootAddress || rootId, rootRelay]); + } else if (rootIValue) { + tags.push(['I', rootIValue, rootIRelay]); + } else { + tags.push(['E', rootId, rootRelay]); + } + tags.push(['K', String(rootKind)]); + if (rootPubkey && !rootIValue) { + tags.push(['P', rootPubkey, rootRelay]); + } + tags.push(['e', parent.id, parentRelay]); + tags.push(['k', String(parentKind)]); + tags.push(['p', parentPubkey, parentRelay]); + } else { + // Top-level comment or regular event + if (rootAddress) { + tags.push([isRootA ? 'A' : isRootI ? 'I' : 'E', rootAddress || rootId, rootRelay]); + tags.push(['K', String(rootKind)]); + if (rootPubkey) { + tags.push(['P', rootPubkey, rootRelay]); + } + tags.push([isRootA ? 'a' : isRootI ? 'i' : 'e', parentAddress || parent.id, parentRelay]); + tags.push(['e', parent.id, parentRelay]); + tags.push(['k', String(parentKind)]); + tags.push(['p', parentPubkey, parentRelay]); + } else if (rootIValue) { + tags.push(['I', rootIValue, rootIRelay]); + tags.push(['K', String(rootKind)]); + tags.push(['i', rootIValue, rootIRelay]); + tags.push(['k', String(parentKind)]); + } else { + tags.push(['E', rootId, rootRelay]); + tags.push(['K', String(rootKind)]); + if (rootPubkey) { + tags.push(['P', rootPubkey, rootRelay]); + } + tags.push(['e', parent.id, parentRelay]); + tags.push(['k', String(parentKind)]); + tags.push(['p', parentPubkey, parentRelay]); + } + } + } } - tags.push(['k', String(parentKind), '', '']); - tags.push(['p', parentPubkey, parentRelay, '']); + // Prefix Nostr addresses before publishing + const prefixedContent = prefixNostrAddresses(content); + // Create a completely plain object to avoid proxy cloning issues const eventToSign = { kind: Number(kind), created_at: Number(Math.floor(Date.now() / 1000)), tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]), - content: String(content), - pubkey: String(pubkey), + content: String(prefixedContent), + pubkey: pk, }; let sig, id; @@ -384,16 +505,16 @@ {userProfile.displayName || userProfile.name || - nip19.npubEncode(pubkey || '').slice(0, 8) + "..."} + nip19.npubEncode(pubkey() || '').slice(0, 8) + "..."} {/if} - {#if !pubkey} + {#if !pubkey()} Please sign in to post comments. Your comments will be signed with your current account. diff --git a/src/lib/components/EventInput.svelte b/src/lib/components/EventInput.svelte index c7f49d5..70d2707 100644 --- a/src/lib/components/EventInput.svelte +++ b/src/lib/components/EventInput.svelte @@ -1,9 +1,10 @@ {#if pubkey} @@ -285,6 +356,11 @@ Kind must be an integer between 0 and 65535 (NIP-01). {/if} + {#if kind === 30040} +
+ 30040 - Publication Index: {get30040EventDescription()} +
+ {/if}
@@ -330,7 +406,7 @@ oninput={handleDTagInput} placeholder='d-tag (auto-generated from title)' class='input input-bordered w-full' - required + required={requiresDTag(kind)} /> {#if dTagError}
{dTagError}
diff --git a/src/lib/components/EventSearch.svelte b/src/lib/components/EventSearch.svelte index d731169..68b5358 100644 --- a/src/lib/components/EventSearch.svelte +++ b/src/lib/components/EventSearch.svelte @@ -1,5 +1,6 @@
-
+
e.key === "Enter" && searchEvent(true)} + onkeydown={(e: KeyboardEvent) => e.key === "Enter" && searchEvent(true)} /> - +
diff --git a/src/lib/utils/event_input_utils.ts b/src/lib/utils/event_input_utils.ts index 4a35c5e..a22fef0 100644 --- a/src/lib/utils/event_input_utils.ts +++ b/src/lib/utils/event_input_utils.ts @@ -56,6 +56,38 @@ export function validateAsciiDoc(content: string): { valid: boolean; reason?: st return { valid: true }; } +/** + * Validates that a 30040 event set will be created correctly. + * Returns { valid, reason }. + */ +export function validate30040EventSet(content: string): { valid: boolean; reason?: string } { + // First validate as AsciiDoc + const asciiDocValidation = validateAsciiDoc(content); + if (!asciiDocValidation.valid) { + return asciiDocValidation; + } + + // Check that we have at least one section + const sectionsResult = splitAsciiDocSections(content); + if (sectionsResult.sections.length === 0) { + return { valid: false, reason: '30040 events must contain at least one section.' }; + } + + // Check that we have a document title + const documentTitle = extractAsciiDocDocumentHeader(content); + if (!documentTitle) { + return { valid: false, reason: '30040 events must have a document title (line starting with "=").' }; + } + + // Check that the content will result in an empty 30040 event + // The 30040 event should have empty content, with all content split into 30041 events + if (!content.trim().startsWith('=')) { + return { valid: false, reason: '30040 events must start with a document title ("=").' }; + } + + return { valid: true }; +} + // ========================= // Extraction & Normalization // ========================= @@ -105,22 +137,63 @@ function extractMarkdownTopHeader(content: string): string | null { /** * Splits AsciiDoc content into sections at each '==' header. Returns array of section strings. + * Document title (= header) is excluded from sections and only used for the index event title. + * Section headers (==) are discarded from content. + * Text between document header and first section becomes a "Preamble" section. */ -function splitAsciiDocSections(content: string): string[] { +function splitAsciiDocSections(content: string): { sections: string[]; sectionHeaders: string[]; hasPreamble: boolean } { const lines = content.split(/\r?\n/); const sections: string[] = []; + const sectionHeaders: string[] = []; let current: string[] = []; + let foundFirstSection = false; + let hasPreamble = false; + let preambleContent: string[] = []; + for (const line of lines) { - if (/^==\s+/.test(line) && current.length > 0) { - sections.push(current.join('\n').trim()); - current = []; + // Skip document title lines (= header) + if (/^=\s+/.test(line)) { + continue; + } + + // If we encounter a section header (==) and we have content, start a new section + if (/^==\s+/.test(line)) { + if (current.length > 0) { + sections.push(current.join('\n').trim()); + current = []; + } + + // Extract section header for title tag + const headerMatch = line.match(/^==\s+(.+)$/); + if (headerMatch) { + sectionHeaders.push(headerMatch[1].trim()); + } + + foundFirstSection = true; + } else if (foundFirstSection) { + // Only add lines to current section if we've found the first section + current.push(line); + } else { + // Text before first section becomes preamble + if (line.trim() !== '') { + preambleContent.push(line); + } } - current.push(line); } + + // Add the last section if (current.length > 0) { sections.push(current.join('\n').trim()); } - return sections; + + // Add preamble as first section if it exists + if (preambleContent.length > 0) { + sections.unshift(preambleContent.join('\n').trim()); + sectionHeaders.unshift('Preamble'); + hasPreamble = true; + } + + return { sections, sectionHeaders, hasPreamble }; } // ========================= @@ -144,15 +217,29 @@ export function build30040EventSet( tags: [string, string][], baseEvent: Partial & { pubkey: string; created_at: number } ): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } { + console.log('=== build30040EventSet called ==='); + console.log('Input content:', content); + console.log('Input tags:', tags); + console.log('Input baseEvent:', baseEvent); + const ndk = getNdk(); - const sections = splitAsciiDocSections(content); - const sectionHeaders = extractAsciiDocSectionHeaders(content); + console.log('NDK instance:', ndk); + + const sectionsResult = splitAsciiDocSections(content); + const sections = sectionsResult.sections; + const sectionHeaders = sectionsResult.sectionHeaders; + console.log('Sections:', sections); + console.log('Section headers:', sectionHeaders); + const dTags = sectionHeaders.length === sections.length ? sectionHeaders.map(normalizeDTagValue) : sections.map((_, i) => `section${i}`); + console.log('D tags:', dTags); + const sectionEvents: NDKEvent[] = sections.map((section, i) => { const header = sectionHeaders[i] || `Section ${i + 1}`; const dTag = dTags[i]; + console.log(`Creating section ${i}:`, { header, dTag, content: section }); return new NDKEventClass(ndk, { kind: 30041, content: section, @@ -165,10 +252,23 @@ export function build30040EventSet( created_at: baseEvent.created_at, }); }); + + // Create proper a tags with format: kind:pubkey:d-tag + const aTags = dTags.map(dTag => ['a', `30041:${baseEvent.pubkey}:${dTag}`] as [string, string]); + console.log('A tags:', aTags); + + // Extract document title for the index event + const documentTitle = extractAsciiDocDocumentHeader(content); + const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : 'index'; + console.log('Index event:', { documentTitle, indexDTag }); + const indexTags = [ ...tags, - ...dTags.map(d => ['a', d] as [string, string]), + ['d', indexDTag], + ['title', documentTitle || 'Untitled'], + ...aTags, ]; + const indexEvent: NDKEvent = new NDKEventClass(ndk, { kind: 30040, content: '', @@ -176,6 +276,8 @@ export function build30040EventSet( pubkey: baseEvent.pubkey, created_at: baseEvent.created_at, }); + console.log('Final index event:', indexEvent); + console.log('=== build30040EventSet completed ==='); return { indexEvent, sectionEvents }; } @@ -216,4 +318,82 @@ export function getDTagForEvent(kind: number, content: string, existingDTag?: st } return null; +} + +/** + * Returns a description of what a 30040 event structure should be. + */ +export function get30040EventDescription(): string { + return `30040 events are publication indexes that contain: +- Empty content (metadata only) +- A d-tag for the publication identifier +- A title tag for the publication title +- A tags referencing 30041 content events (one per section) + +The content is split into sections, each published as a separate 30041 event.`; +} + +/** + * Analyzes a 30040 event to determine if it was created correctly. + * Returns { valid, issues } where issues is an array of problems found. + */ +export function analyze30040Event(event: { content: string; tags: [string, string][]; kind: number }): { valid: boolean; issues: string[] } { + const issues: string[] = []; + + // Check if it's actually a 30040 event + if (event.kind !== 30040) { + issues.push('Event is not kind 30040'); + return { valid: false, issues }; + } + + // Check if content is empty (30040 should be metadata only) + if (event.content && event.content.trim() !== '') { + issues.push('30040 events should have empty content (metadata only)'); + issues.push('Content should be split into separate 30041 events'); + } + + // Check for required tags + const hasTitle = event.tags.some(([k, v]) => k === 'title' && v); + const hasDTag = event.tags.some(([k, v]) => k === 'd' && v); + const hasATags = event.tags.some(([k, v]) => k === 'a' && v); + + if (!hasTitle) { + issues.push('Missing title tag'); + } + if (!hasDTag) { + issues.push('Missing d tag'); + } + if (!hasATags) { + issues.push('Missing a tags (should reference 30041 content events)'); + } + + // Check if a tags have the correct format (kind:pubkey:d-tag) + const aTags = event.tags.filter(([k, v]) => k === 'a' && v); + for (const [, value] of aTags) { + if (!value.includes(':')) { + issues.push(`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`); + } + } + + return { valid: issues.length === 0, issues }; +} + +/** + * Returns guidance on how to fix incorrect 30040 events. + */ +export function get30040FixGuidance(): string { + return `To fix a 30040 event: + +1. **Content Issue**: 30040 events should have empty content. All content should be split into separate 30041 events. + +2. **Structure**: A proper 30040 event should contain: + - Empty content + - d tag: publication identifier + - title tag: publication title + - a tags: references to 30041 content events (format: "30041:pubkey:d-tag") + +3. **Process**: When creating a 30040 event: + - Write your content with document title (= Title) and sections (== Section) + - The system will automatically split it into one 30040 index event and multiple 30041 content events + - The 30040 will have empty content and reference the 30041s via a tags`; } \ No newline at end of file diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts index 739c8f5..b30d46b 100644 --- a/src/lib/utils/nostrUtils.ts +++ b/src/lib/utils/nostrUtils.ts @@ -507,3 +507,79 @@ export async function signEvent(event: { const sig = await schnorr.sign(id, event.pubkey); return bytesToHex(sig); } + +/** + * Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:" + * if they are not already prefixed and are not part of a hyperlink + */ +export function prefixNostrAddresses(content: string): string { + // Regex to match Nostr addresses that are not already prefixed with "nostr:" + // and are not part of a markdown link or HTML link + // Must be followed by at least 20 alphanumeric characters to be considered an address + const nostrAddressPattern = /\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g; + + return content.replace(nostrAddressPattern, (match, offset) => { + // Check if this match is part of a markdown link [text](url) + const beforeMatch = content.substring(0, offset); + const afterMatch = content.substring(offset + match.length); + + // Check if it's part of a markdown link + const beforeBrackets = beforeMatch.lastIndexOf('['); + const afterParens = afterMatch.indexOf(')'); + + if (beforeBrackets !== -1 && afterParens !== -1) { + const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets); + const lastOpenBracket = textBeforeBrackets.lastIndexOf('['); + const lastCloseBracket = textBeforeBrackets.lastIndexOf(']'); + + // If we have [text] before this, it might be a markdown link + if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) { + return match; // Don't prefix if it's part of a markdown link + } + } + + // Check if it's part of an HTML link + const beforeHref = beforeMatch.lastIndexOf('href='); + if (beforeHref !== -1) { + const afterHref = afterMatch.indexOf('"'); + if (afterHref !== -1) { + return match; // Don't prefix if it's part of an HTML link + } + } + + // Check if it's already prefixed with "nostr:" + const beforeNostr = beforeMatch.lastIndexOf('nostr:'); + if (beforeNostr !== -1) { + const textAfterNostr = beforeMatch.substring(beforeNostr + 6); + if (!textAfterNostr.includes(' ')) { + return match; // Already prefixed + } + } + + // Additional check: ensure it's actually a valid Nostr address format + // The part after the prefix should be a valid bech32 string + const addressPart = match.substring(4); // Remove npub, nprofile, etc. + if (addressPart.length < 20) { + return match; // Too short to be a valid address + } + + // Check if it looks like a valid bech32 string (alphanumeric, no special chars) + if (!/^[a-zA-Z0-9]+$/.test(addressPart)) { + return match; // Not a valid bech32 format + } + + // Additional check: ensure the word before is not a common word that would indicate + // this is just a general reference, not an actual address + const wordBefore = beforeMatch.match(/\b(\w+)\s*$/); + if (wordBefore) { + const beforeWord = wordBefore[1].toLowerCase(); + const commonWords = ['the', 'a', 'an', 'this', 'that', 'my', 'your', 'his', 'her', 'their', 'our']; + if (commonWords.includes(beforeWord)) { + return match; // Likely just a general reference, not an actual address + } + } + + // Prefix with "nostr:" + return `nostr:${match}`; + }); +} diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte index fd1236b..3ed8137 100644 --- a/src/routes/events/+page.svelte +++ b/src/routes/events/+page.svelte @@ -2,6 +2,7 @@ import { Heading, P } from "flowbite-svelte"; import { onMount } from "svelte"; import { page } from "$app/stores"; + import { goto } from "$app/navigation"; import type { NDKEvent } from "$lib/utils/nostrUtils"; import EventSearch from "$lib/components/EventSearch.svelte"; import EventDetails from "$lib/components/EventDetails.svelte"; @@ -52,6 +53,10 @@ profile = null; } + function handleClear() { + goto('/events', { replaceState: true }); + } + function getSummary(event: NDKEvent): string | undefined { return getMatchingTags(event, "summary")[0]?.[1]; } @@ -61,6 +66,10 @@ return getMatchingTags(event, "deferrel")[0]?.[1]; } + function onLoadingChange(val: boolean) { + loading = val; + } + $effect(() => { const id = $page.url.searchParams.get("id"); const dTag = $page.url.searchParams.get("d"); @@ -75,6 +84,13 @@ dTagValue = dTag ? dTag.toLowerCase() : null; searchValue = null; } + + // Reset state if both id and dTag are absent + if (!id && !dTag) { + event = null; + searchResults = []; + profile = null; + } }); onMount(() => { @@ -108,6 +124,8 @@ {event} onEventFound={handleEventFound} onSearchResults={handleSearchResults} + onClear={handleClear} + onLoadingChange={onLoadingChange} /> {#if $isLoggedIn && !event && searchResults.length === 0}