You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
671 lines
21 KiB
671 lines
21 KiB
<script lang="ts"> |
|
import { |
|
getTitleTagForEvent, |
|
getDTagForEvent, |
|
requiresDTag, |
|
validateNotAsciidoc, |
|
validateAsciiDoc, |
|
build30040EventSet, |
|
titleToDTag, |
|
validate30040EventSet, |
|
get30040EventDescription, |
|
analyze30040Event, |
|
get30040FixGuidance, |
|
} from "$lib/utils/event_input_utils"; |
|
import { |
|
extractDocumentMetadata, |
|
metadataToTags, |
|
removeMetadataFromContent |
|
} from "$lib/utils/asciidoc_metadata"; |
|
import { get } from "svelte/store"; |
|
import { ndkInstance } from "$lib/ndk"; |
|
import { userPubkey } from "$lib/stores/authStore.Svelte"; |
|
import { userStore } from "$lib/stores/userStore"; |
|
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; |
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
|
import { prefixNostrAddresses } from "$lib/utils/nostrUtils"; |
|
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; |
|
import { Button } from "flowbite-svelte"; |
|
import { goto } from "$app/navigation"; |
|
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; |
|
|
|
let kind = $state<number>(30040); |
|
let tags = $state<[string, string][]>([]); |
|
let content = $state(""); |
|
let createdAt = $state<number>(Math.floor(Date.now() / 1000)); |
|
let loading = $state(false); |
|
let error = $state<string | null>(null); |
|
let success = $state<string | null>(null); |
|
let publishedRelays = $state<string[]>([]); |
|
|
|
let title = $state(""); |
|
let dTag = $state(""); |
|
let titleManuallyEdited = $state(false); |
|
let dTagManuallyEdited = $state(false); |
|
let dTagError = $state(""); |
|
let lastPublishedEventId = $state<string | null>(null); |
|
let showWarning = $state(false); |
|
let warningMessage = $state(""); |
|
let pendingPublish = $state(false); |
|
let extractedMetadata = $state<[string, string][]>([]); |
|
|
|
/** |
|
* Extracts the first Markdown/AsciiDoc header as the title. |
|
*/ |
|
function extractTitleFromContent(content: string): string { |
|
// Match Markdown (# Title) or AsciiDoc (= Title) headers |
|
// Look for document title (=) first, then fall back to section headers (==) |
|
const documentMatch = content.match(/^=\s*(.+)$/m); |
|
if (documentMatch) { |
|
return documentMatch[1].trim(); |
|
} |
|
|
|
// If no document title, look for the first section header |
|
const sectionMatch = content.match(/^==\s*(.+)$/m); |
|
if (sectionMatch) { |
|
return sectionMatch[1].trim(); |
|
} |
|
|
|
return ""; |
|
} |
|
|
|
function handleContentInput(e: Event) { |
|
content = (e.target as HTMLTextAreaElement).value; |
|
if (!titleManuallyEdited) { |
|
const extracted = extractTitleFromContent(content); |
|
console.log("Content input - extracted title:", extracted); |
|
title = extracted; |
|
} |
|
|
|
// Extract metadata from AsciiDoc content for 30040 and 30041 events |
|
if (kind === 30040 || kind === 30041) { |
|
try { |
|
const { metadata } = extractDocumentMetadata(content); |
|
const metadataTags = metadataToTags(metadata); |
|
extractedMetadata = metadataTags; |
|
console.log("Extracted metadata:", metadata); |
|
console.log("Metadata tags:", metadataTags); |
|
} catch (error) { |
|
console.error("Error extracting metadata:", error); |
|
extractedMetadata = []; |
|
} |
|
} else { |
|
extractedMetadata = []; |
|
} |
|
} |
|
|
|
function handleTitleInput(e: Event) { |
|
title = (e.target as HTMLInputElement).value; |
|
titleManuallyEdited = true; |
|
} |
|
|
|
function handleDTagInput(e: Event) { |
|
dTag = (e.target as HTMLInputElement).value; |
|
dTagManuallyEdited = true; |
|
} |
|
|
|
$effect(() => { |
|
console.log( |
|
"Effect running - title:", |
|
title, |
|
"dTagManuallyEdited:", |
|
dTagManuallyEdited, |
|
); |
|
if (!dTagManuallyEdited) { |
|
const newDTag = titleToDTag(title); |
|
console.log("Setting dTag to:", newDTag); |
|
dTag = newDTag; |
|
} |
|
}); |
|
|
|
function updateTag(index: number, key: string, value: string): void { |
|
tags = tags.map((t, i) => (i === index ? [key, value] : t)); |
|
} |
|
function addTag(): void { |
|
tags = [...tags, ["", ""]]; |
|
} |
|
function removeTag(index: number): void { |
|
tags = tags.filter((_, i) => i !== index); |
|
} |
|
|
|
function addExtractedTag(key: string, value: string): void { |
|
// Check if tag already exists |
|
const existingIndex = tags.findIndex(([k]) => k === key); |
|
if (existingIndex >= 0) { |
|
// Update existing tag |
|
tags = tags.map((t, i) => (i === existingIndex ? [key, value] : t)); |
|
} else { |
|
// Add new tag |
|
tags = [...tags, [key, value]]; |
|
} |
|
} |
|
|
|
function isValidKind(kind: number | string): boolean { |
|
const n = Number(kind); |
|
return Number.isInteger(n) && n >= 0 && n <= 65535; |
|
} |
|
|
|
function validate(): { valid: boolean; reason?: string; warning?: string } { |
|
const currentUserPubkey = get(userPubkey as any); |
|
const userState = get(userStore); |
|
|
|
// Try userPubkey first, then fallback to userStore |
|
const pubkey = currentUserPubkey || userState.pubkey; |
|
if (!pubkey) return { valid: false, reason: "Not logged in." }; |
|
|
|
if (!content.trim()) return { valid: false, reason: "Content required." }; |
|
if (kind === 30023) { |
|
const v = validateNotAsciidoc(content); |
|
if (!v.valid) return v; |
|
} |
|
if (kind === 30040) { |
|
const v = validate30040EventSet(content); |
|
if (!v.valid) return v; |
|
if (v.warning) return { valid: true, warning: v.warning }; |
|
} |
|
if (kind === 30041 || kind === 30818) { |
|
const v = validateAsciiDoc(content); |
|
if (!v.valid) return v; |
|
} |
|
return { valid: true }; |
|
} |
|
|
|
function handleSubmit(e: Event) { |
|
e.preventDefault(); |
|
dTagError = ""; |
|
error = null; // Clear any previous errors |
|
|
|
if (requiresDTag(kind) && (!dTag || dTag.trim() === "")) { |
|
dTagError = "A d-tag is required."; |
|
return; |
|
} |
|
|
|
const validation = validate(); |
|
if (!validation.valid) { |
|
error = validation.reason || "Validation failed."; |
|
return; |
|
} |
|
|
|
if (validation.warning) { |
|
warningMessage = validation.warning; |
|
showWarning = true; |
|
pendingPublish = true; |
|
return; |
|
} |
|
|
|
handlePublish(); |
|
} |
|
|
|
async function handlePublish(): Promise<void> { |
|
error = null; |
|
success = null; |
|
publishedRelays = []; |
|
loading = true; |
|
createdAt = Math.floor(Date.now() / 1000); |
|
|
|
try { |
|
const ndk = get(ndkInstance); |
|
const currentUserPubkey = get(userPubkey as any); |
|
const userState = get(userStore); |
|
|
|
// Try userPubkey first, then fallback to userStore |
|
const pubkey = currentUserPubkey || userState.pubkey; |
|
if (!ndk || !pubkey) { |
|
error = "NDK or pubkey missing."; |
|
loading = false; |
|
return; |
|
} |
|
const pubkeyString = String(pubkey); |
|
|
|
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) { |
|
error = "Invalid public key: must be a 64-character hex string."; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Validate before proceeding |
|
const validation = validate(); |
|
if (!validation.valid) { |
|
error = validation.reason || "Validation failed."; |
|
loading = false; |
|
return; |
|
} |
|
|
|
const baseEvent = { pubkey: pubkeyString, created_at: createdAt }; |
|
let events: NDKEvent[] = []; |
|
|
|
console.log("Publishing event with kind:", kind); |
|
console.log("Content length:", content.length); |
|
console.log("Content preview:", content.substring(0, 100)); |
|
console.log("Tags:", tags); |
|
console.log("Title:", title); |
|
console.log("DTag:", dTag); |
|
|
|
if (Number(kind) === 30040) { |
|
console.log("=== 30040 EVENT CREATION START ==="); |
|
console.log("Creating 30040 event set with content:", content); |
|
try { |
|
const { indexEvent, sectionEvents } = build30040EventSet( |
|
content, |
|
tags, |
|
baseEvent, |
|
); |
|
console.log("Index event:", indexEvent); |
|
console.log("Section events:", sectionEvents); |
|
// Publish all 30041 section events first, then the 30040 index event |
|
events = [...sectionEvents, indexEvent]; |
|
console.log("Total events to publish:", events.length); |
|
|
|
// Debug the index event to ensure it's correct |
|
const indexEventData = { |
|
content: indexEvent.content, |
|
tags: indexEvent.tags.map( |
|
(tag) => [tag[0], tag[1]] as [string, string], |
|
), |
|
kind: indexEvent.kind || 30040, |
|
}; |
|
const analysis = debug30040Event(indexEventData); |
|
if (!analysis.valid) { |
|
console.warn("30040 index event has issues:", analysis.issues); |
|
} |
|
console.log("=== 30040 EVENT CREATION END ==="); |
|
} catch (error) { |
|
console.error("Error in build30040EventSet:", error); |
|
error = `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}`; |
|
loading = false; |
|
return; |
|
} |
|
} else { |
|
let eventTags = [...tags]; |
|
|
|
// Ensure d-tag exists and has a value for addressable events |
|
if (requiresDTag(kind)) { |
|
const dTagIndex = eventTags.findIndex(([k]) => k === "d"); |
|
const dTagValue = dTag.trim() || getDTagForEvent(kind, content, ""); |
|
|
|
if (dTagValue) { |
|
if (dTagIndex >= 0) { |
|
// Update existing d-tag |
|
eventTags[dTagIndex] = ["d", dTagValue]; |
|
} else { |
|
// Add new d-tag |
|
eventTags = [...eventTags, ["d", dTagValue]]; |
|
} |
|
} |
|
} |
|
|
|
// Add title tag if we have a title |
|
const titleValue = title.trim() || getTitleTagForEvent(kind, content); |
|
if (titleValue) { |
|
eventTags = [...eventTags, ["title", titleValue]]; |
|
} |
|
|
|
// For AsciiDoc events, remove metadata from content |
|
let finalContent = content; |
|
if (kind === 30040 || kind === 30041) { |
|
finalContent = removeMetadataFromContent(content); |
|
} |
|
|
|
// Prefix Nostr addresses before publishing |
|
const prefixedContent = prefixNostrAddresses(finalContent); |
|
|
|
// Create event with proper serialization |
|
const eventData = { |
|
kind, |
|
content: prefixedContent, |
|
tags: eventTags, |
|
pubkey: pubkeyString, |
|
created_at: createdAt, |
|
}; |
|
|
|
events = [new NDKEventClass(ndk, eventData)]; |
|
} |
|
|
|
let atLeastOne = false; |
|
let relaysPublished: string[] = []; |
|
|
|
for (let i = 0; i < events.length; i++) { |
|
const event = events[i]; |
|
try { |
|
console.log("Publishing event:", { |
|
kind: event.kind, |
|
content: event.content, |
|
tags: event.tags, |
|
hasContent: event.content && event.content.length > 0, |
|
}); |
|
|
|
// Always sign with a plain object if window.nostr is available |
|
// Create a completely plain object to avoid proxy cloning issues |
|
const plainEvent = { |
|
kind: Number(event.kind), |
|
pubkey: String(event.pubkey), |
|
created_at: Number( |
|
event.created_at ?? Math.floor(Date.now() / 1000), |
|
), |
|
tags: event.tags.map((tag) => [String(tag[0]), String(tag[1])]), |
|
content: String(event.content), |
|
}; |
|
if ( |
|
typeof window !== "undefined" && |
|
window.nostr && |
|
window.nostr.signEvent |
|
) { |
|
const signed = await window.nostr.signEvent(plainEvent); |
|
event.sig = signed.sig; |
|
if ("id" in signed) { |
|
event.id = signed.id as string; |
|
} |
|
} else { |
|
await event.sign(); |
|
} |
|
|
|
// Use direct WebSocket publishing like CommentBox does |
|
const signedEvent = { |
|
...plainEvent, |
|
id: event.id, |
|
sig: event.sig, |
|
}; |
|
|
|
// Try to publish to relays directly |
|
const relays = [ |
|
"wss://relay.damus.io", |
|
"wss://relay.nostr.band", |
|
"wss://nos.lol", |
|
...$activeOutboxRelays, |
|
...$activeInboxRelays, |
|
]; |
|
let published = false; |
|
|
|
for (const relayUrl of relays) { |
|
try { |
|
const ws = await WebSocketPool.instance.acquire(relayUrl); |
|
|
|
await new Promise<void>((resolve, reject) => { |
|
const timeout = setTimeout(() => { |
|
WebSocketPool.instance.release(ws); |
|
reject(new Error("Timeout")); |
|
}, 5000); |
|
|
|
ws.onmessage = (e) => { |
|
const [type, id, ok, message] = JSON.parse(e.data); |
|
if (type === "OK" && id === signedEvent.id) { |
|
clearTimeout(timeout); |
|
if (ok) { |
|
published = true; |
|
relaysPublished.push(relayUrl); |
|
WebSocketPool.instance.release(ws); |
|
resolve(); |
|
} else { |
|
WebSocketPool.instance.release(ws); |
|
reject(new Error(message)); |
|
} |
|
} |
|
}; |
|
|
|
// Send the event to the relay |
|
ws.send(JSON.stringify(["EVENT", signedEvent])); |
|
}); |
|
if (published) break; |
|
} catch (e) { |
|
console.error(`Failed to publish to ${relayUrl}:`, e); |
|
} |
|
} |
|
|
|
if (published) { |
|
atLeastOne = true; |
|
// For 30040, set lastPublishedEventId to the index event (last in array) |
|
if (Number(kind) === 30040) { |
|
if (i === events.length - 1) { |
|
lastPublishedEventId = event.id; |
|
} |
|
} else { |
|
lastPublishedEventId = event.id; |
|
} |
|
} |
|
} catch (signError) { |
|
console.error("Error signing/publishing event:", signError); |
|
error = `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}`; |
|
loading = false; |
|
return; |
|
} |
|
} |
|
|
|
loading = false; |
|
if (atLeastOne) { |
|
publishedRelays = relaysPublished; |
|
success = `Published to ${relaysPublished.length} relay(s).`; |
|
} else { |
|
error = "Failed to publish to any relay."; |
|
} |
|
} catch (err) { |
|
console.error("Error in handlePublish:", err); |
|
error = `Publishing failed: ${err instanceof Error ? err.message : "Unknown error"}`; |
|
loading = false; |
|
} |
|
} |
|
|
|
/** |
|
* Debug function to analyze a 30040 event and provide guidance. |
|
*/ |
|
function debug30040Event(eventData: { |
|
content: string; |
|
tags: [string, string][]; |
|
kind: number; |
|
}) { |
|
const analysis = analyze30040Event(eventData); |
|
console.log("30040 Event Analysis:", analysis); |
|
if (!analysis.valid) { |
|
console.log("Guidance:", get30040FixGuidance()); |
|
} |
|
return analysis; |
|
} |
|
|
|
function viewPublishedEvent() { |
|
if (lastPublishedEventId) { |
|
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`); |
|
} |
|
} |
|
|
|
function confirmWarning() { |
|
showWarning = false; |
|
pendingPublish = false; |
|
handlePublish(); |
|
} |
|
|
|
function cancelWarning() { |
|
showWarning = false; |
|
pendingPublish = false; |
|
warningMessage = ""; |
|
} |
|
</script> |
|
|
|
<div |
|
class="w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg" |
|
> |
|
<h2 class="text-xl font-bold mb-4">Publish Nostr Event</h2> |
|
<form class="space-y-4" onsubmit={handleSubmit}> |
|
<div> |
|
<label class="block font-medium mb-1" for="event-kind">Kind</label> |
|
<input |
|
id="event-kind" |
|
type="text" |
|
class="input input-bordered w-full" |
|
bind:value={kind} |
|
required |
|
/> |
|
{#if !isValidKind(kind)} |
|
<div class="text-red-600 text-sm mt-1"> |
|
Kind must be an integer between 0 and 65535 (NIP-01). |
|
</div> |
|
{/if} |
|
{#if Number(kind) === 30040} |
|
<div |
|
class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-50 dark:text-blue-800 p-2 rounded whitespace-pre-wrap" |
|
> |
|
<strong>30040 - Publication Index:</strong> |
|
{get30040EventDescription()} |
|
</div> |
|
{/if} |
|
</div> |
|
<div> |
|
<label class="block font-medium mb-1" for="tags-container">Tags</label> |
|
|
|
<!-- Extracted Metadata Section --> |
|
{#if extractedMetadata.length > 0} |
|
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> |
|
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2"> |
|
Extracted Metadata (from AsciiDoc header) |
|
</h4> |
|
<div class="space-y-2"> |
|
{#each extractedMetadata as [key, value], i} |
|
<div class="flex gap-2 items-center"> |
|
<span class="text-xs text-blue-600 dark:text-blue-400 min-w-[60px]">{key}:</span> |
|
<input |
|
type="text" |
|
class="input input-bordered input-sm flex-1 text-sm" |
|
value={value} |
|
readonly |
|
/> |
|
<button |
|
type="button" |
|
class="btn btn-sm btn-outline btn-primary" |
|
onclick={() => addExtractedTag(key, value)} |
|
> |
|
Add to Tags |
|
</button> |
|
</div> |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<div id="tags-container" class="space-y-2"> |
|
{#each tags as [key, value], i} |
|
<div class="flex gap-2"> |
|
<input |
|
type="text" |
|
class="input input-bordered flex-1" |
|
placeholder="tag" |
|
bind:value={tags[i][0]} |
|
oninput={(e) => |
|
updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])} |
|
/> |
|
<input |
|
type="text" |
|
class="input input-bordered flex-1" |
|
placeholder="value" |
|
bind:value={tags[i][1]} |
|
oninput={(e) => |
|
updateTag(i, tags[i][0], (e.target as HTMLInputElement).value)} |
|
/> |
|
<button |
|
type="button" |
|
class="btn btn-error btn-sm" |
|
onclick={() => removeTag(i)} |
|
disabled={tags.length === 1}>×</button |
|
> |
|
</div> |
|
{/each} |
|
<div class="flex justify-end"> |
|
<button |
|
type="button" |
|
class="btn btn-primary btn-sm border border-primary-600 px-3 py-1" |
|
onclick={addTag}>Add Tag</button |
|
> |
|
</div> |
|
</div> |
|
</div> |
|
<div> |
|
<label class="block font-medium mb-1" for="event-content">Content</label> |
|
<textarea |
|
id="event-content" |
|
bind:value={content} |
|
oninput={handleContentInput} |
|
placeholder="Content (start with a header for the title)" |
|
class="textarea textarea-bordered w-full h-40" |
|
required |
|
></textarea> |
|
</div> |
|
<div> |
|
<label class="block font-medium mb-1" for="event-title">Title</label> |
|
<input |
|
type="text" |
|
id="event-title" |
|
bind:value={title} |
|
oninput={handleTitleInput} |
|
placeholder="Title (auto-filled from header)" |
|
class="input input-bordered w-full" |
|
/> |
|
</div> |
|
<div> |
|
<label class="block font-medium mb-1" for="event-d-tag">d-tag</label> |
|
<input |
|
type="text" |
|
id="event-d-tag" |
|
bind:value={dTag} |
|
oninput={handleDTagInput} |
|
placeholder="d-tag (auto-generated from title)" |
|
class="input input-bordered w-full" |
|
required={requiresDTag(kind)} |
|
/> |
|
{#if dTagError} |
|
<div class="text-red-600 text-sm mt-1">{dTagError}</div> |
|
{/if} |
|
</div> |
|
<div class="flex justify-end"> |
|
<button |
|
type="submit" |
|
class="btn btn-primary border border-primary-600 px-4 py-2" |
|
disabled={loading}>Publish</button |
|
> |
|
</div> |
|
{#if loading} |
|
<span class="ml-2 text-gray-500">Publishing...</span> |
|
{/if} |
|
{#if error} |
|
<div class="mt-2 text-red-600">{error}</div> |
|
{/if} |
|
{#if success} |
|
<div class="mt-2 text-green-600">{success}</div> |
|
<div class="text-xs text-gray-500"> |
|
Relays: {publishedRelays.join(", ")} |
|
</div> |
|
{#if lastPublishedEventId} |
|
<div class="mt-2 text-green-700"> |
|
Event ID: <span class="font-mono">{lastPublishedEventId}</span> |
|
<Button |
|
onclick={viewPublishedEvent} |
|
class="text-blue-600 dark:text-blue-500 hover:underline ml-2" |
|
> |
|
View your event |
|
</Button> |
|
</div> |
|
{/if} |
|
{/if} |
|
</form> |
|
</div> |
|
|
|
{#if showWarning} |
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> |
|
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg max-w-md mx-4"> |
|
<h3 class="text-lg font-bold mb-4">Warning</h3> |
|
<p class="mb-4">{warningMessage}</p> |
|
<div class="flex justify-end space-x-2"> |
|
<button |
|
type="button" |
|
class="btn btn-secondary" |
|
onclick={cancelWarning} |
|
> |
|
Cancel |
|
</button> |
|
<button |
|
type="button" |
|
class="btn btn-primary" |
|
onclick={confirmWarning} |
|
> |
|
Continue |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if}
|
|
|