Browse Source

Implemented event publisher subfiles

master
silberengel 7 months ago
parent
commit
b6be86a6ae
  1. 919
      src/lib/components/EventInput.svelte
  2. 162
      src/lib/components/event_input/EventForm.svelte
  3. 172
      src/lib/components/event_input/EventPreview.svelte
  4. 342
      src/lib/components/event_input/TagManager.svelte
  5. 277
      src/lib/components/event_input/eventServices.ts
  6. 63
      src/lib/components/event_input/types.ts
  7. 90
      src/lib/components/event_input/validation.ts

919
src/lib/components/EventInput.svelte

File diff suppressed because it is too large Load Diff

162
src/lib/components/event_input/EventForm.svelte

@ -0,0 +1,162 @@ @@ -0,0 +1,162 @@
<script lang="ts">
import { Tooltip } from "flowbite-svelte";
import type { EventData, TagData, ValidationResult } from "./types";
import { validateEvent } from "./validation";
// AI-NOTE: 2025-01-24 - EventForm component handles basic form inputs and validation
// This component focuses on event kind and content, with validation feedback
let {
eventData = $bindable(),
tags,
onvalidate,
}: {
eventData: EventData;
tags: TagData[];
onvalidate: (isValid: boolean, error?: string, warning?: string) => void;
} = $props();
let validationError = $state<string | null>(null);
let validationWarning = $state<string | null>(null);
/**
* Validates the current form data
*/
function validateForm(): ValidationResult {
return validateEvent(eventData, tags);
}
/**
* Handles form validation
*/
function handleValidate(e: Event) {
e.preventDefault();
validationError = null;
validationWarning = null;
const validation = validateForm();
if (!validation.valid) {
validationError = validation.reason || "Validation failed.";
onvalidate(false, validation.reason || "Validation failed.");
return;
}
if (validation.warning) {
validationWarning = validation.warning;
onvalidate(true, undefined, validation.warning);
} else {
onvalidate(true);
}
}
/**
* Validates kind input
*/
function isValidKind(kind: number | string): boolean {
const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535;
}
/**
* Gets kind description
*/
function getKindDescription(kind: number): string {
switch (kind) {
case 1:
return "Text Note";
case 30023:
return "Long-form Content";
case 30040:
return "Publication Index";
case 30041:
return "Publication Section";
case 30818:
return "AsciiDoc Document";
default:
return "Custom Event";
}
}
</script>
<form class="space-y-4" onsubmit={handleValidate}>
<!-- Event Kind -->
<div>
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300" for="event-kind">
Kind
</label>
<input
id="event-kind"
type="number"
class="input input-bordered w-full"
bind:value={eventData.kind}
min="0"
max="65535"
required
/>
{#if !isValidKind(eventData.kind)}
<div class="text-red-600 dark:text-red-400 text-sm mt-1">
Kind must be an integer between 0 and 65535 (NIP-01).
</div>
{/if}
{#if isValidKind(eventData.kind)}
<div class="flex items-center gap-2 mt-1">
<span class="text-sm text-gray-600 dark:text-gray-400">
{getKindDescription(eventData.kind)}
</span>
{#if eventData.kind === 30040}
<Tooltip class="tooltip-leather" type="auto" placement="bottom">
<button
type="button"
class="w-6 h-6 rounded-full bg-blue-500 hover:bg-blue-600 text-white flex items-center justify-center text-sm font-bold border border-blue-600 shadow-sm"
title="Learn more about Publication Index events"
>
?
</button>
<div class="max-w-sm p-2 text-xs">
<strong>30040 - Publication Index:</strong> Events that organize AsciiDoc content into structured publications with metadata tags and section references.
</div>
</Tooltip>
{/if}
</div>
{/if}
</div>
<!-- Event Content -->
<div>
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300" for="event-content">
Content
</label>
<textarea
id="event-content"
bind:value={eventData.content}
placeholder="Content (start with a header for the title)"
class="textarea textarea-bordered w-full h-40"
required
></textarea>
<!-- Content hints based on kind -->
{#if eventData.kind === 30023}
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Use Markdown format for long-form content. Do not use AsciiDoc headers (=).
</div>
{:else if eventData.kind === 30040 || eventData.kind === 30041 || eventData.kind === 30818}
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Use AsciiDoc format. Start with a document title (=) and include section headers (==).
</div>
{/if}
</div>
<!-- Validation Messages -->
{#if validationError}
<div class="text-red-600 dark:text-red-400 text-sm">
{validationError}
</div>
{/if}
{#if validationWarning}
<div class="text-yellow-600 dark:text-yellow-400 text-sm">
Warning: {validationWarning}
</div>
{/if}
</form>

172
src/lib/components/event_input/EventPreview.svelte

@ -0,0 +1,172 @@ @@ -0,0 +1,172 @@
<script lang="ts">
import { get } from "svelte/store";
import { userStore } from "$lib/stores/userStore";
import { prefixNostrAddresses } from "$lib/utils/nostrUtils";
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata";
import { build30040EventSet } from "$lib/utils/event_input_utils";
import type { EventData, TagData, EventPreview } from "./types";
// AI-NOTE: 2025-01-24 - EventPreview component shows a preview of the event that will be published
// This component generates a preview based on the current form data
let {
ndk,
eventData,
tags,
showJsonPreview,
onTogglePreview,
}: {
ndk: any;
eventData: EventData;
tags: TagData[];
showJsonPreview: boolean;
onTogglePreview: () => void;
} = $props();
/**
* 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]);
}
/**
* Generates event preview
*/
let eventPreview = $derived.by(() => {
const userState = get(userStore);
const pubkey = userState.pubkey;
if (!pubkey) {
return null;
}
// Build the event data similar to how it's done in publishing
const baseEvent = {
pubkey: String(pubkey),
created_at: eventData.createdAt,
kind: Number(eventData.kind)
};
if (Number(eventData.kind) === 30040) {
// For 30040, we need to show the index event structure
try {
// Convert tags to compatible format (exclude preset tags)
const presetTagKeys = ["version", "d", "title"];
const compatibleTags: [string, string][] = tags
.filter(tag => tag.key.trim() !== "" && !presetTagKeys.includes(tag.key))
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]);
// Create a mock NDK instance for preview
const mockNdk = { sign: async () => ({ sig: "mock_signature" }) };
const { indexEvent } = build30040EventSet(
eventData.content,
compatibleTags,
baseEvent,
mockNdk as any,
);
// Add preset tags from UI (version, d, title)
const finalTags = indexEvent.tags.filter(tag => !presetTagKeys.includes(tag[0]));
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]) {
finalTags.push(["version", versionTag.values[0]]);
}
if (dTag && dTag.values[0]) {
finalTags.push(["d", dTag.values[0]]);
}
if (titleTag && titleTag.values[0]) {
finalTags.push(["title", titleTag.values[0]]);
}
return {
type: "30040_index_event",
event: {
id: "[will be generated]",
pubkey: String(pubkey),
created_at: eventData.createdAt,
kind: 30040,
tags: finalTags,
content: indexEvent.content,
sig: "[will be generated]"
}
};
} catch (error) {
return {
type: "error",
message: `Failed to generate 30040 preview: ${error instanceof Error ? error.message : "Unknown error"}`
};
}
} else {
// For other event types
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
const prefixedContent = prefixNostrAddresses(finalContent);
return {
type: "standard_event",
event: {
id: "[will be generated]",
pubkey: String(pubkey),
created_at: eventData.createdAt,
kind: Number(eventData.kind),
tags: eventTags,
content: prefixedContent,
sig: "[will be generated]"
}
};
}
});
</script>
<!-- Event Preview Section -->
<div class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Event Preview</h3>
<button
type="button"
class="btn btn-sm btn-outline btn-secondary"
onclick={onTogglePreview}
>
{showJsonPreview ? 'Hide' : 'Show'} JSON Preview
</button>
</div>
{#if showJsonPreview}
{#if eventPreview}
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
{#if eventPreview.type === 'error'}
<div class="text-red-600 dark:text-red-400 text-sm">
{eventPreview.message}
</div>
{:else}
<div class="mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Event Type: {eventPreview.type === '30040_index_event' ? '30040 Publication Index' : 'Standard Event'}
</span>
</div>
<pre class="text-xs bg-white dark:bg-gray-900 p-3 rounded border overflow-x-auto text-gray-800 dark:text-gray-200 font-mono whitespace-pre-wrap">{JSON.stringify(eventPreview.event, null, 2)}</pre>
{/if}
</div>
{:else}
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-700">
<div class="text-yellow-800 dark:text-yellow-200 text-sm">
Please log in to see the event preview.
</div>
</div>
{/if}
{/if}
</div>

342
src/lib/components/event_input/TagManager.svelte

@ -0,0 +1,342 @@ @@ -0,0 +1,342 @@
<script lang="ts">
import { extractSmartMetadata, metadataToTags } from "$lib/utils/asciidoc_metadata";
import { titleToDTag, requiresDTag } from "$lib/utils/event_input_utils";
import type { TagData, PresetTag } from "./types";
// AI-NOTE: 2025-01-24 - TagManager component handles tag management with preset tags
// This component automatically manages preset tags based on event kind and content
let {
tags = $bindable(),
kind,
content,
}: {
tags: TagData[];
kind: number;
content: string;
} = $props();
let removedTags = $state<Set<string>>(new Set());
let extractedMetadata = $state<[string, string][]>([]);
let lastContent = $state("");
let lastKind = $state(0);
// Define preset tags for different event kinds
let presetTags = $derived.by(() => {
const presets: PresetTag[] = [];
// Version tag for 30040 events
if (kind === 30040) {
presets.push({
key: "version",
defaultValue: "1",
required: true,
autoUpdate: false,
description: "Publication version"
});
}
// D-tag and title for addressable events
if (requiresDTag(kind)) {
presets.push({
key: "d",
defaultValue: "default-title",
required: true,
autoUpdate: true,
description: "Document identifier (derived from title)"
});
presets.push({
key: "title",
defaultValue: "Default Title",
required: true,
autoUpdate: true,
description: "Document title (extracted from content)"
});
}
return presets;
});
// Extract metadata from content for AsciiDoc events
$effect(() => {
if (kind === 30040 || kind === 30041) {
const { metadata } = extractSmartMetadata(content);
extractedMetadata = metadataToTags(metadata);
} else {
extractedMetadata = [];
}
});
// Manage preset tags automatically
$effect(() => {
// Only run this effect when content or kind changes, not when tags change
if (content === lastContent && kind === lastKind) {
return; // Skip if nothing has changed
}
lastContent = content;
lastKind = kind;
const currentTags = [...tags]; // Create a copy to avoid mutation
const newTags: TagData[] = [];
// Add preset tags
for (const preset of presetTags) {
if (removedTags.has(preset.key)) continue;
let value = preset.defaultValue;
// Auto-update values based on content
if (preset.autoUpdate && content.trim()) {
if (preset.key === "title") {
const { metadata } = extractSmartMetadata(content);
value = metadata.title || preset.defaultValue;
} else if (preset.key === "d") {
const { metadata } = extractSmartMetadata(content);
value = titleToDTag(metadata.title || "") || preset.defaultValue;
}
}
// Find existing tag or create new one
const existingTag = currentTags.find(t => t.key === preset.key);
if (existingTag) {
// For preset tags, always ensure exactly one value
if (preset.autoUpdate) {
newTags.push({
key: preset.key,
values: [value] // Only keep the first (primary) value
});
} else {
newTags.push({
key: preset.key,
values: [existingTag.values[0] || preset.defaultValue] // Keep user value or default
});
}
} else {
newTags.push({
key: preset.key,
values: [value]
});
}
}
// Add non-preset tags (avoid duplicates)
for (const tag of currentTags) {
const isPresetKey = presetTags.some(p => p.key === tag.key);
const alreadyAdded = newTags.some(t => t.key === tag.key);
if (!isPresetKey && !alreadyAdded) {
newTags.push(tag);
}
}
// Ensure there's always an empty tag row for user input
if (newTags.length === 0 || newTags[newTags.length - 1].key !== "") {
newTags.push({ key: "", values: [""] });
}
// Only update if the tags have actually changed
const tagsChanged = JSON.stringify(newTags) !== JSON.stringify(currentTags);
if (tagsChanged) {
tags = newTags;
}
});
/**
* Adds a new tag
*/
function addTag(): void {
tags = [...tags, { key: "", values: [""] }];
}
/**
* Removes a tag at the specified index
*/
function removeTag(index: number): void {
const tagKey = tags[index]?.key;
if (tagKey) {
removedTags.add(tagKey);
}
tags = tags.filter((_, i) => i !== index);
}
/**
* Adds a value to a tag
*/
function addTagValue(tagIndex: number): void {
tags = tags.map((tag, i) => {
if (i === tagIndex) {
return { ...tag, values: [...tag.values, ""] };
}
return tag;
});
}
/**
* Removes a value from a tag
*/
function removeTagValue(tagIndex: number, valueIndex: number): void {
tags = tags.map((tag, i) => {
if (i === tagIndex) {
const newValues = tag.values.filter((_, vi) => vi !== valueIndex);
return { ...tag, values: newValues.length > 0 ? newValues : [""] };
}
return tag;
});
}
/**
* Updates a tag key
*/
function updateTagKey(index: number, newKey: string): void {
tags = tags.map((tag, i) => {
if (i === index) {
return { ...tag, key: newKey };
}
return tag;
});
}
/**
* Updates a tag value
*/
function updateTagValue(tagIndex: number, valueIndex: number, newValue: string): void {
tags = tags.map((tag, i) => {
if (i === tagIndex) {
const newValues = [...tag.values];
newValues[valueIndex] = newValue;
return { ...tag, values: newValues };
}
return tag;
});
}
/**
* Checks if a tag is a preset tag
*/
function isPresetTag(tagKey: string): boolean {
return presetTags.some(p => p.key === tagKey);
}
/**
* Gets preset tag info
*/
function getPresetTagInfo(tagKey: string): PresetTag | undefined {
return presetTags.find(p => p.key === tagKey);
}
</script>
<div class="space-y-4">
<label for="tags-container" class="block font-medium mb-1 text-gray-700 dark:text-gray-300">
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="text-sm text-blue-700 dark:text-blue-300">
{extractedMetadata.map(([key, value]) => `${key}: ${value}`).join(', ')}
</div>
</div>
{/if}
<!-- Tags Container -->
<div id="tags-container" class="space-y-2">
{#each tags as tag, i}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 space-y-2">
<!-- Tag Key Row -->
<div class="flex gap-2 items-center">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[60px]">Tag:</span>
<input
type="text"
class="input input-bordered flex-1"
placeholder="tag key (e.g., q, p, e)"
value={tag.key}
oninput={(e) => updateTagKey(i, (e.target as HTMLInputElement).value)}
/>
{#if isPresetTag(tag.key)}
<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
Preset
</span>
{/if}
<button
type="button"
class="btn btn-error btn-sm"
onclick={() => removeTag(i)}
>
×
</button>
</div>
<!-- Preset Tag Description -->
{#if isPresetTag(tag.key)}
{@const presetInfo = getPresetTagInfo(tag.key)}
{#if presetInfo}
<div class="text-xs text-gray-600 dark:text-gray-400 italic">
{presetInfo.description}
{#if presetInfo.autoUpdate}
(auto-updates from content)
{/if}
</div>
{/if}
{/if}
<!-- Tag Values -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[60px]">Values:</span>
<button
type="button"
class="btn btn-sm btn-outline btn-primary"
onclick={() => addTagValue(i)}
>
Add Value
</button>
</div>
{#each tag.values as value, valueIndex}
<div class="flex gap-2 items-center">
<span class="text-xs text-gray-500 dark:text-gray-400 min-w-[40px]">
{valueIndex + 1}:
</span>
<input
type="text"
class="input input-bordered flex-1"
placeholder="value"
value={value}
oninput={(e) => updateTagValue(i, valueIndex, (e.target as HTMLInputElement).value)}
/>
{#if tag.values.length > 1}
<button
type="button"
class="btn btn-sm btn-outline btn-error"
onclick={() => removeTagValue(i, valueIndex)}
>
×
</button>
{/if}
</div>
{/each}
</div>
</div>
{/each}
<!-- Add Tag Button -->
<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>

277
src/lib/components/event_input/eventServices.ts

@ -0,0 +1,277 @@ @@ -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<PublishResult> {
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<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 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<LoadEventResult | null> {
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;
}

63
src/lib/components/event_input/types.ts

@ -0,0 +1,63 @@ @@ -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;
}

90
src/lib/components/event_input/validation.ts

@ -0,0 +1,90 @@ @@ -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);
}
Loading…
Cancel
Save