From b6be86a6ae78113193c8d1ac78ee4401ce5d441d Mon Sep 17 00:00:00 2001 From: silberengel Date: Tue, 19 Aug 2025 21:47:20 +0200 Subject: [PATCH] Implemented event publisher subfiles --- src/lib/components/EventInput.svelte | 983 +++--------------- .../components/event_input/EventForm.svelte | 162 +++ .../event_input/EventPreview.svelte | 172 +++ .../components/event_input/TagManager.svelte | 342 ++++++ .../components/event_input/eventServices.ts | 277 +++++ src/lib/components/event_input/types.ts | 63 ++ src/lib/components/event_input/validation.ts | 90 ++ 7 files changed, 1265 insertions(+), 824 deletions(-) create mode 100644 src/lib/components/event_input/EventForm.svelte create mode 100644 src/lib/components/event_input/EventPreview.svelte create mode 100644 src/lib/components/event_input/TagManager.svelte create mode 100644 src/lib/components/event_input/eventServices.ts create mode 100644 src/lib/components/event_input/types.ts create mode 100644 src/lib/components/event_input/validation.ts diff --git a/src/lib/components/EventInput.svelte b/src/lib/components/EventInput.svelte index bf83f4d..d296827 100644 --- a/src/lib/components/EventInput.svelte +++ b/src/lib/components/EventInput.svelte @@ -1,129 +1,47 @@ -
-

Publish Nostr Event

+
+
+

Publish Nostr Event

+
+ + +
+
@@ -728,229 +229,63 @@

-
-
- - - {#if !isValidKind(kind)} -
- Kind must be an integer between 0 and 65535 (NIP-01). -
- {/if} - {#if Number(kind) === 30040} -
- Publication Index - - -
- 30040 - Publication Index: Events that organize AsciiDoc content into structured publications with metadata tags and section references. -
-
-
- {/if} -
-
- - - - {#if extractedMetadata.length > 0} -
-

- Extracted Metadata (from AsciiDoc header) -

-
- {extractedMetadata.map(([key, value]) => `${key}: ${value}`).join(', ')} -
-
- {/if} - -
- {#each tags as tag, i} -
-
- Tag: - - -
- -
-
- Values: - -
- - {#each tag.slice(1) as value, valueIndex} -
- {valueIndex + 1}: - - {#if tag.length > 2} - - {/if} -
- {/each} -
-
- {/each} -
- -
-
-
-
- - -
+ + + + + + + +
+ +
-
- - + + {#if loading} +
Publishing...
+ {/if} + {#if error} +
{error}
+ {/if} + {#if success} +
{success}
+
+ Relays: {publishedRelays.join(", ")}
- {#if loading} - Publishing... - {/if} - {#if error} -
{error}
- {/if} - {#if success} -
{success}
-
- Relays: {publishedRelays.join(", ")} + {#if lastPublishedEventId} +
+ Event ID: {lastPublishedEventId} +
- {#if lastPublishedEventId} -
- Event ID: {lastPublishedEventId} - -
- {/if} {/if} - - - -
-
-

Event Preview

- -
- - {#if showJsonPreview} - {@const preview = eventPreview()} - {#if preview} -
- {#if preview.type === 'error'} -
- {preview.message} -
- {:else} -
- - Event Type: {preview.type === '30040_index_event' ? '30040 Publication Index' : 'Standard Event'} - -
-
{JSON.stringify(preview.event, null, 2)}
- {/if} -
- {:else} -
-
- Please log in to see the event preview. -
-
- {/if} - {/if} -
- - {#if showWarning} -
-
-

Warning

-

{warningMessage}

-
- - -
-
-
{/if} + + + showJsonPreview = !showJsonPreview} + />
diff --git a/src/lib/components/event_input/EventForm.svelte b/src/lib/components/event_input/EventForm.svelte new file mode 100644 index 0000000..94bf99f --- /dev/null +++ b/src/lib/components/event_input/EventForm.svelte @@ -0,0 +1,162 @@ + + +
+ +
+ + + {#if !isValidKind(eventData.kind)} +
+ Kind must be an integer between 0 and 65535 (NIP-01). +
+ {/if} + {#if isValidKind(eventData.kind)} +
+ + {getKindDescription(eventData.kind)} + + {#if eventData.kind === 30040} + + +
+ 30040 - Publication Index: Events that organize AsciiDoc content into structured publications with metadata tags and section references. +
+
+ {/if} +
+ {/if} +
+ + +
+ + + + + {#if eventData.kind === 30023} +
+ Use Markdown format for long-form content. Do not use AsciiDoc headers (=). +
+ {:else if eventData.kind === 30040 || eventData.kind === 30041 || eventData.kind === 30818} +
+ Use AsciiDoc format. Start with a document title (=) and include section headers (==). +
+ {/if} +
+ + + {#if validationError} +
+ {validationError} +
+ {/if} + {#if validationWarning} +
+ Warning: {validationWarning} +
+ {/if} + + +
diff --git a/src/lib/components/event_input/EventPreview.svelte b/src/lib/components/event_input/EventPreview.svelte new file mode 100644 index 0000000..55742fb --- /dev/null +++ b/src/lib/components/event_input/EventPreview.svelte @@ -0,0 +1,172 @@ + + + +
+
+

Event Preview

+ +
+ + {#if showJsonPreview} + {#if eventPreview} +
+ {#if eventPreview.type === 'error'} +
+ {eventPreview.message} +
+ {:else} +
+ + Event Type: {eventPreview.type === '30040_index_event' ? '30040 Publication Index' : 'Standard Event'} + +
+
{JSON.stringify(eventPreview.event, null, 2)}
+ {/if} +
+ {:else} +
+
+ Please log in to see the event preview. +
+
+ {/if} + {/if} +
diff --git a/src/lib/components/event_input/TagManager.svelte b/src/lib/components/event_input/TagManager.svelte new file mode 100644 index 0000000..648fe56 --- /dev/null +++ b/src/lib/components/event_input/TagManager.svelte @@ -0,0 +1,342 @@ + + +
+ + + + {#if extractedMetadata.length > 0} +
+

+ Extracted Metadata (from AsciiDoc header) +

+
+ {extractedMetadata.map(([key, value]) => `${key}: ${value}`).join(', ')} +
+
+ {/if} + + +
+ {#each tags as tag, i} +
+ +
+ Tag: + updateTagKey(i, (e.target as HTMLInputElement).value)} + /> + {#if isPresetTag(tag.key)} + + Preset + + {/if} + +
+ + + {#if isPresetTag(tag.key)} + {@const presetInfo = getPresetTagInfo(tag.key)} + {#if presetInfo} +
+ {presetInfo.description} + {#if presetInfo.autoUpdate} + (auto-updates from content) + {/if} +
+ {/if} + {/if} + + +
+
+ Values: + +
+ + {#each tag.values as value, valueIndex} +
+ + {valueIndex + 1}: + + updateTagValue(i, valueIndex, (e.target as HTMLInputElement).value)} + /> + {#if tag.values.length > 1} + + {/if} +
+ {/each} +
+
+ {/each} + + +
+ +
+
+
diff --git a/src/lib/components/event_input/eventServices.ts b/src/lib/components/event_input/eventServices.ts new file mode 100644 index 0000000..84cb072 --- /dev/null +++ b/src/lib/components/event_input/eventServices.ts @@ -0,0 +1,277 @@ +/** + * Event publishing and loading services + */ + +import { get } from "svelte/store"; +import { userStore } from "$lib/stores/userStore"; +import NDK, { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; +import type { NDKEvent } from "$lib/utils/nostrUtils"; +import { prefixNostrAddresses } from "$lib/utils/nostrUtils"; +import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; + +import { WebSocketPool } from "$lib/data_structures/websocket_pool"; +import { anonymousRelays } from "$lib/consts"; +import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; +import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata"; +import { build30040EventSet } from "$lib/utils/event_input_utils"; +import type { EventData, TagData, PublishResult, LoadEventResult } from "./types"; + +/** + * Converts TagData array to NDK-compatible format + */ +function convertTagsToNDKFormat(tags: TagData[]): string[][] { + return tags + .filter(tag => tag.key.trim() !== "") + .map(tag => [tag.key, ...tag.values]); +} + +/** + * Publishes an event to relays + */ +export async function publishEvent(ndk: any, eventData: EventData, tags: TagData[]): Promise { + if (!ndk) { + return { success: false, error: "NDK context not available" }; + } + + const userState = get(userStore); + const pubkey = userState.pubkey; + + if (!pubkey) { + return { success: false, error: "User not logged in." }; + } + + const pubkeyString = String(pubkey); + if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) { + return { success: false, error: "Invalid public key: must be a 64-character hex string." }; + } + + const baseEvent = { pubkey: pubkeyString, created_at: eventData.createdAt }; + let events: NDKEvent[] = []; + + console.log("Publishing event with kind:", eventData.kind); + console.log("Content length:", eventData.content.length); + console.log("Content preview:", eventData.content.substring(0, 100)); + console.log("Tags:", tags); + + if (Number(eventData.kind) === 30040) { + console.log("=== 30040 EVENT CREATION START ==="); + console.log("Creating 30040 event set with content:", eventData.content); + + try { + // Get the current d and title values from the UI + const dTagValue = tags.find(tag => tag.key === "d")?.values[0] || ""; + const titleTagValue = tags.find(tag => tag.key === "title")?.values[0] || ""; + + // Convert multi-value tags to the format expected by build30040EventSet + // Filter out d and title tags since we'll add them manually + const compatibleTags: [string, string][] = tags + .filter(tag => tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title") + .map(tag => [tag.key, tag.values[0] || ""] as [string, string]); + + const { indexEvent, sectionEvents } = build30040EventSet( + eventData.content, + compatibleTags, + baseEvent, + ndk, + ); + + // Override the d and title tags with the UI values if they exist + const finalTags = indexEvent.tags.filter(tag => tag[0] !== "d" && tag[0] !== "title"); + if (dTagValue) { + finalTags.push(["d", dTagValue]); + } + if (titleTagValue) { + finalTags.push(["title", titleTagValue]); + } + + // Update the index event with the correct tags + indexEvent.tags = finalTags; + 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); + console.log("=== 30040 EVENT CREATION END ==="); + } catch (error) { + console.error("Error in build30040EventSet:", error); + return { + success: false, + error: `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}` + }; + } + } else { + // Convert multi-value tags to the format expected by NDK + let eventTags = convertTagsToNDKFormat(tags); + + // For AsciiDoc events, remove metadata from content + let finalContent = eventData.content; + if (eventData.kind === 30040 || eventData.kind === 30041) { + finalContent = removeMetadataFromContent(eventData.content); + } + + // Prefix Nostr addresses before publishing + const prefixedContent = prefixNostrAddresses(finalContent); + + // Create event with proper serialization + const eventDataForNDK = { + kind: eventData.kind, + content: prefixedContent, + tags: eventTags, + pubkey: pubkeyString, + created_at: eventData.createdAt, + }; + + events = [new NDKEventClass(ndk, eventDataForNDK)]; + } + + let atLeastOne = false; + let relaysPublished: string[] = []; + let lastEventId: string | null = null; + + 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) => tag.map(String)), + 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 = [ + ...anonymousRelays, + ...get(activeOutboxRelays), + ...get(activeInboxRelays), + ]; + let published = false; + + for (const relayUrl of relays) { + try { + const ws = await WebSocketPool.instance.acquire(relayUrl); + + await new Promise((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 lastEventId to the index event (last in array) + if (Number(eventData.kind) === 30040) { + if (i === events.length - 1) { + lastEventId = event.id; + } + } else { + lastEventId = event.id; + } + } + } catch (signError) { + console.error("Error signing/publishing event:", signError); + return { + success: false, + error: `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}` + }; + } + } + + if (atLeastOne) { + return { + success: true, + eventId: lastEventId || undefined, + relays: relaysPublished + }; + } else { + return { success: false, error: "Failed to publish to any relay." }; + } +} + +/** + * Loads an event by its hex ID + */ +export async function loadEvent(ndk: any, eventId: string): Promise { + if (!ndk) { + throw new Error("NDK context not available"); + } + + const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000); + + if (foundEvent) { + // Convert NDK event format to our format + const eventData: EventData = { + kind: foundEvent.kind || 30040, + content: foundEvent.content || "", + createdAt: Math.floor(Date.now() / 1000), // Use current time for replacement + }; + + // Convert NDK tags format to our format + const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({ + key: tag[0] || "", + values: tag.slice(1) + })); + + return { eventData, tags }; + } + + return null; +} diff --git a/src/lib/components/event_input/types.ts b/src/lib/components/event_input/types.ts new file mode 100644 index 0000000..df7e8f9 --- /dev/null +++ b/src/lib/components/event_input/types.ts @@ -0,0 +1,63 @@ +/** + * Type definitions for the EventInput component system + */ + +export interface EventData { + kind: number; + content: string; + createdAt: number; +} + +export interface TagData { + key: string; + values: string[]; +} + +export interface ValidationResult { + valid: boolean; + reason?: string; + warning?: string; +} + +export interface PublishResult { + success: boolean; + eventId?: string; + relays?: string[]; + error?: string; +} + +export interface LoadEventResult { + eventData: EventData; + tags: TagData[]; +} + +export interface EventPreview { + type: 'standard_event' | '30040_index_event' | 'error'; + event?: { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; + }; + message?: string; +} + +export interface PresetTag { + key: string; + defaultValue: string; + required: boolean; + autoUpdate: boolean; + description: string; +} + +export interface KindConfig { + kind: number; + name: string; + description: string; + presetTags: PresetTag[]; + requiresContent: boolean; + contentValidation?: (content: string) => ValidationResult; +} diff --git a/src/lib/components/event_input/validation.ts b/src/lib/components/event_input/validation.ts new file mode 100644 index 0000000..7fb6609 --- /dev/null +++ b/src/lib/components/event_input/validation.ts @@ -0,0 +1,90 @@ +/** + * Event validation utilities + */ + +import { get } from "svelte/store"; +import { userStore } from "$lib/stores/userStore"; +import type { EventData, TagData, ValidationResult } from "./types"; +import { + validateNotAsciidoc, + validateAsciiDoc, + validate30040EventSet, +} from "$lib/utils/event_input_utils"; + +/** + * Validates an event and its tags + */ +export function validateEvent(eventData: EventData, tags: TagData[]): ValidationResult { + const userState = get(userStore); + + const pubkey = userState.pubkey; + if (!pubkey) { + return { valid: false, reason: "Not logged in." }; + } + + // Content validation - 30040 events don't require content + if (eventData.kind !== 30040 && !eventData.content.trim()) { + return { valid: false, reason: "Content required." }; + } + + // Kind-specific validation + if (eventData.kind === 30023) { + const v = validateNotAsciidoc(eventData.content); + if (!v.valid) return v; + } + + if (eventData.kind === 30040) { + // Check for required tags + const versionTag = tags.find(t => t.key === "version"); + const dTag = tags.find(t => t.key === "d"); + const titleTag = tags.find(t => t.key === "title"); + + if (!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === "") { + return { valid: false, reason: "30040 events require a 'version' tag." }; + } + + if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") { + return { valid: false, reason: "30040 events require a 'd' tag." }; + } + + if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") { + return { valid: false, reason: "30040 events require a 'title' tag." }; + } + + // Validate content format if present + if (eventData.content.trim()) { + const v = validate30040EventSet(eventData.content); + if (!v.valid) return v; + if (v.warning) return { valid: true, warning: v.warning }; + } + } + + if (eventData.kind === 30041 || eventData.kind === 30818) { + const v = validateAsciiDoc(eventData.content); + if (!v.valid) return v; + } + + return { valid: true }; +} + +/** + * Validates that a kind is within valid range + */ +export function isValidKind(kind: number | string): boolean { + const n = Number(kind); + return Number.isInteger(n) && n >= 0 && n <= 65535; +} + +/** + * Validates that a tag has a valid key + */ +export function isValidTagKey(key: string): boolean { + return key.trim().length > 0; +} + +/** + * Validates that a tag has at least one value + */ +export function isValidTag(tag: TagData): boolean { + return isValidTagKey(tag.key) && tag.values.some(v => v.trim().length > 0); +}