Browse Source

Rudimentary comment box and event input box implemented

master
Silberengel 8 months ago
parent
commit
766818b2c6
  1. 167
      src/lib/components/CommentBox.svelte
  2. 31
      src/lib/components/EventDetails.svelte
  3. 363
      src/lib/components/EventInput.svelte
  4. 68
      src/lib/components/RelayActions.svelte
  5. 2
      src/lib/components/RelayStatus.svelte
  6. 2
      src/lib/consts.ts
  7. 37
      src/lib/ndk.ts
  8. 11
      src/lib/stores/authStore.ts
  9. 219
      src/lib/utils/event_input_utils.ts
  10. 140
      src/lib/utils/relayDiagnostics.ts
  11. 25
      src/routes/events/+page.svelte

167
src/lib/components/CommentBox.svelte

@ -11,13 +11,13 @@
import { standardRelays, fallbackRelays } from "$lib/consts"; import { standardRelays, fallbackRelays } from "$lib/consts";
import { userRelays } from "$lib/stores/relayStore"; import { userRelays } from "$lib/stores/relayStore";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { activePubkey } from '$lib/ndk';
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { onMount } from "svelte"; import { onMount } from "svelte";
const props = $props<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
userPubkey: string;
userRelayPreference: boolean; userRelayPreference: boolean;
}>(); }>();
@ -29,12 +29,26 @@
let showOtherRelays = $state(false); let showOtherRelays = $state(false);
let showFallbackRelays = $state(false); let showFallbackRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null); let userProfile = $state<NostrProfile | null>(null);
let pubkey = $state<string | null>(null);
$effect(() => {
pubkey = get(activePubkey);
});
// Fetch user profile on mount // Fetch user profile on mount
onMount(async () => { onMount(() => {
if (props.userPubkey) { const trimmedPubkey = pubkey?.trim();
const npub = nip19.npubEncode(props.userPubkey); if (trimmedPubkey && /^[a-fA-F0-9]{64}$/.test(trimmedPubkey)) {
userProfile = await getUserMetadata(npub); (async () => {
const npub = nip19.npubEncode(trimmedPubkey);
userProfile = await getUserMetadata(npub);
error = null;
})();
} else if (trimmedPubkey) {
userProfile = null;
error = 'Invalid public key: must be a 64-character hex string.';
} else {
userProfile = null;
error = null;
} }
}); });
@ -102,6 +116,22 @@
updatePreview(); updatePreview();
} }
// Helper functions to ensure relay and pubkey are always strings
function getRelayString(relay: any): string {
if (!relay) return '';
if (typeof relay === 'string') return relay;
if (typeof relay.url === 'string') return relay.url;
return '';
}
function getPubkeyString(pubkey: any): string {
if (!pubkey) return '';
if (typeof pubkey === 'string') return pubkey;
if (typeof pubkey.hex === 'function') return pubkey.hex();
if (typeof pubkey.pubkey === 'string') return pubkey.pubkey;
return '';
}
async function handleSubmit( async function handleSubmit(
useOtherRelays = false, useOtherRelays = false,
useFallbackRelays = false, useFallbackRelays = false,
@ -111,53 +141,91 @@
success = null; success = null;
try { try {
if (!props.event.kind) { if (!pubkey || !/^[a-fA-F0-9]{64}$/.test(pubkey)) {
throw new Error("Invalid event: missing kind"); 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');
} }
const kind = props.event.kind === 1 ? 1 : 1111; // Always use kind 1111 for comments
const tags: string[][] = []; const kind = 1111;
const parent = props.event;
if (kind === 1) { // Try to extract root info from parent tags (NIP-22 threading)
// NIP-10 reply let rootKind = parent.kind;
tags.push(["e", props.event.id, "", "reply"]); let rootPubkey = getPubkeyString(parent.pubkey);
tags.push(["p", props.event.pubkey]); let rootRelay = getRelayString(parent.relay);
if (props.event.tags) { let rootId = parent.id;
const rootTag = props.event.tags.find( let rootAddress = '';
(t: string[]) => t[0] === "e" && t[3] === "root", let parentRelay = getRelayString(parent.relay);
); let parentAddress = '';
if (rootTag) { let parentKind = parent.kind;
tags.push(["e", rootTag[1], "", "root"]); let parentPubkey = getPubkeyString(parent.pubkey);
} // Try to find root event info from tags (E/A/I)
// Add all p tags from the parent event let isRootA = false;
props.event.tags let isRootI = false;
.filter((t: string[]) => t[0] === "p") if (parent.tags) {
.forEach((t: string[]) => { const rootE = parent.tags.find((t: string[]) => t[0] === 'E');
if (!tags.some((pt: string[]) => pt[1] === t[1])) { const rootA = parent.tags.find((t: string[]) => t[0] === 'A');
tags.push(["p", t[1]]); const rootI = parent.tags.find((t: string[]) => t[0] === 'I');
} isRootA = !!rootA;
}); isRootI = !!rootI;
if (rootE) {
rootId = rootE[1];
rootRelay = getRelayString(rootE[2]);
rootPubkey = getPubkeyString(rootE[3] || rootPubkey);
rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind;
} else if (rootA) {
rootAddress = rootA[1];
rootRelay = getRelayString(rootA[2]);
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];
rootKind = parent.tags.find((t: string[]) => t[0] === 'K')?.[1] || rootKind;
} }
}
// Compose tags according to NIP-22
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]);
} else { } else {
// NIP-22 comment tags.push(['e', parent.id, parentRelay, parentPubkey]);
tags.push(["E", props.event.id, "", props.event.pubkey]);
tags.push(["K", props.event.kind.toString()]);
tags.push(["P", props.event.pubkey]);
tags.push(["e", props.event.id, "", props.event.pubkey]);
tags.push(["k", props.event.kind.toString()]);
tags.push(["p", props.event.pubkey]);
} }
tags.push(['k', String(parentKind), '', '']);
tags.push(['p', parentPubkey, parentRelay, '']);
// Create a completely plain object to avoid proxy cloning issues
const eventToSign = { const eventToSign = {
kind, kind: Number(kind),
created_at: Math.floor(Date.now() / 1000), created_at: Number(Math.floor(Date.now() / 1000)),
tags, tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]),
content, content: String(content),
pubkey: props.userPubkey, pubkey: String(pubkey),
}; };
const id = getEventHash(eventToSign); let sig, id;
const sig = await signEvent(eventToSign); if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(eventToSign);
sig = signed.sig as string;
if ('id' in signed) {
id = signed.id as string;
} else {
id = getEventHash(eventToSign);
}
} else {
id = getEventHash(eventToSign);
sig = await signEvent(eventToSign);
}
const signedEvent = { const signedEvent = {
...eventToSign, ...eventToSign,
@ -288,10 +356,11 @@
{#if success} {#if success}
<Alert color="green" dismissable> <Alert color="green" dismissable>
Comment published successfully to {success.relay}! Comment published successfully to {success.relay}!<br/>
Event ID: <span class="font-mono">{success.eventId}</span>
<a <a
href="/events?id={nip19.neventEncode({ id: success.eventId })}" href="/events?id={nip19.neventEncode({ id: success.eventId })}"
class="text-primary-600 dark:text-primary-500 hover:underline" class="text-primary-600 dark:text-primary-500 hover:underline ml-2"
> >
View your comment View your comment
</a> </a>
@ -315,16 +384,16 @@
<span class="text-gray-900 dark:text-gray-100"> <span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName || {userProfile.displayName ||
userProfile.name || userProfile.name ||
nip19.npubEncode(props.userPubkey).slice(0, 8) + "..."} nip19.npubEncode(pubkey || '').slice(0, 8) + "..."}
</span> </span>
</div> </div>
{/if} {/if}
<Button <Button
on:click={() => handleSubmit()} on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !props.userPubkey} disabled={isSubmitting || !content.trim() || !pubkey}
class="w-full md:w-auto" class="w-full md:w-auto"
> >
{#if !props.userPubkey} {#if !pubkey}
Not Signed In Not Signed In
{:else if isSubmitting} {:else if isSubmitting}
Publishing... Publishing...
@ -334,7 +403,7 @@
</Button> </Button>
</div> </div>
{#if !props.userPubkey} {#if !pubkey}
<Alert color="yellow" class="mt-4"> <Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your Please sign in to post comments. Your comments will be signed with your
current account. current account.

31
src/lib/components/EventDetails.svelte

@ -37,7 +37,36 @@
let authorDisplayName = $state<string | undefined>(undefined); let authorDisplayName = $state<string | undefined>(undefined);
function getEventTitle(event: NDKEvent): string { function getEventTitle(event: NDKEvent): string {
return getMatchingTags(event, "title")[0]?.[1] || "Untitled"; // First try to get title from title tag
const titleTag = getMatchingTags(event, "title")[0]?.[1];
if (titleTag) {
return titleTag;
}
// For kind 30023 events, extract title from markdown content if no title tag
if (event.kind === 30023 && event.content) {
const match = event.content.match(/^#\s+(.+)$/m);
if (match) {
return match[1].trim();
}
}
// For kind 30040, 30041, and 30818 events, extract title from AsciiDoc content if no title tag
if ((event.kind === 30040 || event.kind === 30041 || event.kind === 30818) && event.content) {
// First try to find a document header (= )
const docMatch = event.content.match(/^=\s+(.+)$/m);
if (docMatch) {
return docMatch[1].trim();
}
// If no document header, try to find the first section header (== )
const sectionMatch = event.content.match(/^==\s+(.+)$/m);
if (sectionMatch) {
return sectionMatch[1].trim();
}
}
return "Untitled";
} }
function getEventSummary(event: NDKEvent): string { function getEventSummary(event: NDKEvent): string {

363
src/lib/components/EventInput.svelte

@ -0,0 +1,363 @@
<script lang='ts'>
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag } from '$lib/utils/event_input_utils';
import { get } from 'svelte/store';
import { ndkInstance, activePubkey } from '$lib/ndk';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { standardRelays } from '$lib/consts';
let kind = $state<number>(30023);
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 pubkey = $state<string | null>(null);
let title = $state('');
let dTag = $state('');
let titleManuallyEdited = $state(false);
let dTagManuallyEdited = $state(false);
let dTagError = $state('');
let lastPublishedEventId = $state<string | null>(null);
$effect(() => {
pubkey = get(activePubkey);
});
/**
* Extracts the first Markdown/AsciiDoc header as the title.
*/
function extractTitleFromContent(content: string): string {
// Match Markdown (# Title) or AsciiDoc (= Title) headers
const match = content.match(/^(#|=)\s*(.+)$/m);
return match ? match[2].trim() : '';
}
function handleContentInput(e: Event) {
content = (e.target as HTMLTextAreaElement).value;
if (!titleManuallyEdited) {
const extracted = extractTitleFromContent(content);
title = extracted;
}
}
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(() => {
if (!dTagManuallyEdited) {
dTag = titleToDTag(title);
}
});
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 isValidKind(kind: number | string): boolean {
const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535;
}
function validate(): { valid: boolean; reason?: string } {
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 || kind === 30041 || kind === 30818) {
const v = validateAsciiDoc(content);
if (!v.valid) return v;
}
return { valid: true };
}
function handleSubmit(e: Event) {
e.preventDefault();
dTagError = '';
if (!dTag || dTag.trim() === '') {
dTagError = 'A d-tag is required.';
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);
if (!ndk || !pubkey) {
error = 'NDK or pubkey missing.';
loading = false;
return;
}
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) {
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, created_at: createdAt };
let events: NDKEvent[] = [];
if (kind === 30040) {
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
events = [indexEvent, ...sectionEvents];
} 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 existingDTag = dTagIndex >= 0 ? eventTags[dTagIndex][1] : '';
const generatedDTag = getDTagForEvent(kind, content, existingDTag);
if (generatedDTag) {
if (dTagIndex >= 0) {
// Update existing d-tag
eventTags[dTagIndex] = ['d', generatedDTag];
} else {
// Add new d-tag
eventTags = [...eventTags, ['d', generatedDTag]];
}
}
}
const title = getTitleTagForEvent(kind, content);
if (title) {
eventTags = [...eventTags, ['title', title]];
}
// Create event with proper serialization
const eventData = {
kind,
content,
tags: eventTags,
pubkey,
created_at: createdAt,
};
events = [new NDKEventClass(ndk, eventData)];
}
let atLeastOne = false;
let relaysPublished: string[] = [];
for (const event of events) {
try {
// 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', ...standardRelays];
let published = false;
for (const relayUrl of relays) {
try {
const ws = new WebSocket(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timeout"));
}, 5000);
ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", signedEvent]));
};
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);
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error("WebSocket error"));
};
});
if (published) break;
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
if (published) {
atLeastOne = true;
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;
}
}
</script>
{#if pubkey}
<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}
</div>
<div>
<label class='block font-medium mb-1' for='tags-container'>Tags</label>
<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}
<button type='button' class='btn btn-secondary btn-sm' onclick={addTag}>Add Tag</button>
</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
/>
{#if dTagError}
<div class='text-red-600 text-sm mt-1'>{dTagError}</div>
{/if}
</div>
<button type='submit' class='btn btn-primary' disabled={loading}>Publish</button>
{#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>
<a
href={'/events?id=' + lastPublishedEventId}
class='text-primary-600 dark:text-primary-500 hover:underline ml-2'
>
View your event
</a>
</div>
{/if}
{/if}
</form>
</div>
{/if}

68
src/lib/components/RelayActions.svelte

@ -19,9 +19,6 @@
let searchingRelays = $state(false); let searchingRelays = $state(false);
let foundRelays = $state<string[]>([]); let foundRelays = $state<string[]>([]);
let broadcasting = $state(false);
let broadcastSuccess = $state(false);
let broadcastError = $state<string | null>(null);
let showRelayModal = $state(false); let showRelayModal = $state(false);
let relaySearchResults = $state< let relaySearchResults = $state<
Record<string, "pending" | "found" | "notfound"> Record<string, "pending" | "found" | "notfound">
@ -33,43 +30,6 @@
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg>`; </svg>`;
// Broadcast icon SVG
const broadcastIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"/>
</svg>`;
async function broadcastEvent() {
if (!event || !$ndkInstance?.activeUser) return;
broadcasting = true;
broadcastSuccess = false;
broadcastError = null;
try {
const connectedRelays = getConnectedRelays();
if (connectedRelays.length === 0) {
throw new Error("No connected relays available");
}
// Create a new event with the same content
const newEvent = createNDKEvent($ndkInstance, {
...event.rawEvent(),
pubkey: $ndkInstance.activeUser.pubkey,
created_at: Math.floor(Date.now() / 1000),
sig: "",
});
// Publish to all relays
await newEvent.publish();
broadcastSuccess = true;
} catch (err) {
console.error("Error broadcasting event:", err);
broadcastError =
err instanceof Error ? err.message : "Failed to broadcast event";
} finally {
broadcasting = false;
}
}
function openRelayModal() { function openRelayModal() {
showRelayModal = true; showRelayModal = true;
relaySearchResults = {}; relaySearchResults = {};
@ -117,17 +77,6 @@
{@html searchIcon} {@html searchIcon}
Where can I find this event? Where can I find this event?
</Button> </Button>
{#if $ndkInstance?.activeUser}
<Button
on:click={broadcastEvent}
disabled={broadcasting}
class="flex items-center"
>
{@html broadcastIcon}
{broadcasting ? "Broadcasting..." : "Broadcast"}
</Button>
{/if}
</div> </div>
{#if foundRelays.length > 0} {#if foundRelays.length > 0}
@ -141,23 +90,6 @@
</div> </div>
{/if} {/if}
{#if broadcastSuccess}
<div class="mt-2 p-2 bg-green-100 text-green-700 rounded">
Event broadcast successfully to:
<div class="flex flex-wrap gap-2 mt-1">
{#each getConnectedRelays() as relay}
<RelayDisplay {relay} />
{/each}
</div>
</div>
{/if}
{#if broadcastError}
<div class="mt-2 p-2 bg-red-100 text-red-700 rounded">
{broadcastError}
</div>
{/if}
<div class="mt-2"> <div class="mt-2">
<span class="font-semibold">Found on:</span> <span class="font-semibold">Found on:</span>
<div class="flex flex-wrap gap-2 mt-1"> <div class="flex flex-wrap gap-2 mt-1">

2
src/lib/components/RelayStatus.svelte

@ -99,6 +99,8 @@
onMount(() => { onMount(() => {
checkWebSocketSupport(); checkWebSocketSupport();
checkEnvironmentForWebSocketDowngrade(); checkEnvironmentForWebSocketDowngrade();
// Run initial relay tests
void runRelayTests();
}); });
function getStatusColor(status: RelayStatus): string { function getStatusColor(status: RelayStatus): string {

2
src/lib/consts.ts

@ -6,7 +6,7 @@ export const standardRelays = [
"wss://thecitadel.nostr1.com", "wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com", "wss://theforest.nostr1.com",
"wss://profiles.nostr1.com", "wss://profiles.nostr1.com",
"wss://gitcitadel.nostr1.com", // Removed gitcitadel.nostr1.com as it's causing connection issues
//'wss://thecitadel.gitcitadel.eu', //'wss://thecitadel.gitcitadel.eu',
//'wss://theforest.gitcitadel.eu', //'wss://theforest.gitcitadel.eu',
]; ];

37
src/lib/ndk.ts

@ -397,21 +397,40 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
// Ensure the URL is using wss:// protocol // Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(url); const secureUrl = ensureSecureWebSocket(url);
// Add connection timeout and error handling
const relay = new NDKRelay( const relay = new NDKRelay(
secureUrl, secureUrl,
NDKRelayAuthPolicies.signIn({ ndk }), NDKRelayAuthPolicies.signIn({ ndk }),
ndk, ndk,
); );
// Set up connection timeout
const connectionTimeout = setTimeout(() => {
console.warn(`[NDK.ts] Connection timeout for ${secureUrl}`);
relay.disconnect();
}, 10000); // 10 second timeout
// Set up custom authentication handling only if user is signed in // Set up custom authentication handling only if user is signed in
if (ndk.signer && ndk.activeUser) { if (ndk.signer && ndk.activeUser) {
const authPolicy = new CustomRelayAuthPolicy(ndk); const authPolicy = new CustomRelayAuthPolicy(ndk);
relay.on("connect", () => { relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`); console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
clearTimeout(connectionTimeout);
authPolicy.authenticate(relay); authPolicy.authenticate(relay);
}); });
} else {
relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
clearTimeout(connectionTimeout);
});
} }
// Add error handling
relay.on("disconnect", () => {
console.debug(`[NDK.ts] Relay disconnected: ${secureUrl}`);
clearTimeout(connectionTimeout);
});
return relay; return relay;
} }
@ -462,7 +481,23 @@ export function initNdk(): NDK {
// Set up custom authentication policy // Set up custom authentication policy
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk });
ndk.connect().then(() => console.debug("[NDK.ts] NDK connected"));
// Connect with better error handling
ndk.connect()
.then(() => {
console.debug("[NDK.ts] NDK connected successfully");
})
.catch((error) => {
console.error("[NDK.ts] Failed to connect NDK:", error);
// Try to reconnect after a delay
setTimeout(() => {
console.debug("[NDK.ts] Attempting to reconnect...");
ndk.connect().catch((retryError) => {
console.error("[NDK.ts] Reconnection failed:", retryError);
});
}, 5000);
});
return ndk; return ndk;
} }

11
src/lib/stores/authStore.ts

@ -0,0 +1,11 @@
import { writable, derived } from 'svelte/store';
/**
* Stores the user's public key if logged in, or null otherwise.
*/
export const userPubkey = writable<string | null>(null);
/**
* Derived store indicating if the user is logged in.
*/
export const isLoggedIn = derived(userPubkey, ($userPubkey) => !!$userPubkey);

219
src/lib/utils/event_input_utils.ts

@ -0,0 +1,219 @@
import type { NDKEvent } from './nostrUtils';
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
// =========================
// Validation
// =========================
/**
* Returns true if the event kind requires a d-tag (kinds 30000-39999).
*/
export function requiresDTag(kind: number): boolean {
return kind >= 30000 && kind <= 39999;
}
/**
* Returns true if the tags array contains at least one d-tag with a non-empty value.
*/
export function hasDTag(tags: [string, string][]): boolean {
return tags.some(([k, v]) => k === 'd' && v && v.trim() !== '');
}
/**
* Returns true if the content contains AsciiDoc headers (lines starting with '=' or '==').
*/
function containsAsciiDocHeaders(content: string): boolean {
return /^={1,}\s+/m.test(content);
}
/**
* Validates that content does NOT contain AsciiDoc headers (for kind 30023).
* Returns { valid, reason }.
*/
export function validateNotAsciidoc(content: string): { valid: boolean; reason?: string } {
if (containsAsciiDocHeaders(content)) {
return {
valid: false,
reason: 'Kind 30023 must not contain AsciiDoc headers (lines starting with = or ==).',
};
}
return { valid: true };
}
/**
* Validates AsciiDoc content. Must start with '=' and contain at least one '==' section header.
* Returns { valid, reason }.
*/
export function validateAsciiDoc(content: string): { valid: boolean; reason?: string } {
if (!content.trim().startsWith('=')) {
return { valid: false, reason: 'AsciiDoc must start with a document title ("=").' };
}
if (!/^==\s+/m.test(content)) {
return { valid: false, reason: 'AsciiDoc must contain at least one section header ("==").' };
}
return { valid: true };
}
// =========================
// Extraction & Normalization
// =========================
/**
* Normalize a string for use as a d-tag: lowercase, hyphens, alphanumeric only.
*/
function normalizeDTagValue(header: string): string {
return header
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Converts a title string to a valid d-tag (lowercase, hyphens, no punctuation).
*/
export function titleToDTag(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
}
/**
* Extracts the first AsciiDoc document header (line starting with '= ').
*/
function extractAsciiDocDocumentHeader(content: string): string | null {
const match = content.match(/^=\s+(.+)$/m);
return match ? match[1].trim() : null;
}
/**
* Extracts all section headers (lines starting with '== ').
*/
function extractAsciiDocSectionHeaders(content: string): string[] {
return Array.from(content.matchAll(/^==\s+(.+)$/gm)).map(m => m[1].trim());
}
/**
* Extracts the topmost Markdown # header (line starting with '# ').
*/
function extractMarkdownTopHeader(content: string): string | null {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : null;
}
/**
* Splits AsciiDoc content into sections at each '==' header. Returns array of section strings.
*/
function splitAsciiDocSections(content: string): string[] {
const lines = content.split(/\r?\n/);
const sections: string[] = [];
let current: string[] = [];
for (const line of lines) {
if (/^==\s+/.test(line) && current.length > 0) {
sections.push(current.join('\n').trim());
current = [];
}
current.push(line);
}
if (current.length > 0) {
sections.push(current.join('\n').trim());
}
return sections;
}
// =========================
// Event Construction
// =========================
/**
* Returns the current NDK instance from the store.
*/
function getNdk() {
return get(ndkInstance);
}
/**
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section.
* Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header).
* The 30040 index event references all 30041s by their d-tag.
*/
export function build30040EventSet(
content: string,
tags: [string, string][],
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number }
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } {
const ndk = getNdk();
const sections = splitAsciiDocSections(content);
const sectionHeaders = extractAsciiDocSectionHeaders(content);
const dTags = sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`);
const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`;
const dTag = dTags[i];
return new NDKEventClass(ndk, {
kind: 30041,
content: section,
tags: [
...tags,
['d', dTag],
['title', header],
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
});
const indexTags = [
...tags,
...dTags.map(d => ['a', d] as [string, string]),
];
const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040,
content: '',
tags: indexTags,
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
return { indexEvent, sectionEvents };
}
/**
* Returns the appropriate title tag for a given event kind and content.
* - 30041, 30818: AsciiDoc document header (first '= ' line)
* - 30023: Markdown topmost '# ' header
*/
export function getTitleTagForEvent(kind: number, content: string): string | null {
if (kind === 30041 || kind === 30818) {
return extractAsciiDocDocumentHeader(content);
}
if (kind === 30023) {
return extractMarkdownTopHeader(content);
}
return null;
}
/**
* Returns the appropriate d-tag value for a given event kind and content.
* - 30023: Normalized markdown header
* - 30041, 30818: Normalized AsciiDoc document header
* - 30040: Uses existing d-tag or generates from content
*/
export function getDTagForEvent(kind: number, content: string, existingDTag?: string): string | null {
if (existingDTag && existingDTag.trim() !== '') {
return existingDTag.trim();
}
if (kind === 30023) {
const title = extractMarkdownTopHeader(content);
return title ? normalizeDTagValue(title) : null;
}
if (kind === 30041 || kind === 30818) {
const title = extractAsciiDocDocumentHeader(content);
return title ? normalizeDTagValue(title) : null;
}
return null;
}

140
src/lib/utils/relayDiagnostics.ts

@ -0,0 +1,140 @@
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts';
import NDK from '@nostr-dev-kit/ndk';
export interface RelayDiagnostic {
url: string;
connected: boolean;
requiresAuth: boolean;
error?: string;
responseTime?: number;
}
/**
* Tests connection to a single relay
*/
export async function testRelay(url: string): Promise<RelayDiagnostic> {
const startTime = Date.now();
return new Promise((resolve) => {
const ws = new WebSocket(url);
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
ws.close();
resolve({
url,
connected: false,
requiresAuth: false,
error: 'Connection timeout',
responseTime: Date.now() - startTime,
});
}
}, 5000);
ws.onopen = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
ws.close();
resolve({
url,
connected: true,
requiresAuth: false,
responseTime: Date.now() - startTime,
});
}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
resolve({
url,
connected: false,
requiresAuth: false,
error: 'WebSocket error',
responseTime: Date.now() - startTime,
});
}
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === 'NOTICE' && data[1]?.includes('auth-required')) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
ws.close();
resolve({
url,
connected: true,
requiresAuth: true,
responseTime: Date.now() - startTime,
});
}
}
};
});
}
/**
* Tests all relays and returns diagnostic information
*/
export async function testAllRelays(): Promise<RelayDiagnostic[]> {
const allRelays = [...new Set([...standardRelays, ...anonymousRelays, ...fallbackRelays])];
console.log('[RelayDiagnostics] Testing', allRelays.length, 'relays...');
const results = await Promise.allSettled(
allRelays.map(url => testRelay(url))
);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
} else {
return {
url: allRelays[index],
connected: false,
requiresAuth: false,
error: 'Test failed',
};
}
});
}
/**
* Gets working relays from diagnostic results
*/
export function getWorkingRelays(diagnostics: RelayDiagnostic[]): string[] {
return diagnostics
.filter(d => d.connected)
.map(d => d.url);
}
/**
* Logs relay diagnostic results to console
*/
export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void {
console.group('[RelayDiagnostics] Results');
const working = diagnostics.filter(d => d.connected);
const failed = diagnostics.filter(d => !d.connected);
console.log(`✅ Working relays (${working.length}):`);
working.forEach(d => {
console.log(` - ${d.url}${d.requiresAuth ? ' (requires auth)' : ''}${d.responseTime ? ` (${d.responseTime}ms)` : ''}`);
});
if (failed.length > 0) {
console.log(`❌ Failed relays (${failed.length}):`);
failed.forEach(d => {
console.log(` - ${d.url}: ${d.error || 'Unknown error'}`);
});
}
console.groupEnd();
}

25
src/routes/events/+page.svelte

@ -9,6 +9,10 @@
import CommentBox from "$lib/components/CommentBox.svelte"; import CommentBox from "$lib/components/CommentBox.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils"; import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import EventInput from '$lib/components/EventInput.svelte';
import { userPubkey, isLoggedIn } from '$lib/stores/authStore';
import RelayStatus from '$lib/components/RelayStatus.svelte';
import { testAllRelays, logRelayDiagnostics } from '$lib/utils/relayDiagnostics';
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -26,7 +30,6 @@
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
} | null>(null); } | null>(null);
let userPubkey = $state<string | null>(null);
let userRelayPreference = $state(false); let userRelayPreference = $state(false);
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
@ -74,10 +77,14 @@
} }
}); });
onMount(async () => { onMount(() => {
// Get user's pubkey and relay preference from localStorage // Initialize userPubkey from localStorage if available
userPubkey = localStorage.getItem("userPubkey"); const pubkey = localStorage.getItem('userPubkey');
userRelayPreference = localStorage.getItem("useUserRelays") === "true"; userPubkey.set(pubkey);
userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
// Run relay diagnostics to help identify connection issues
testAllRelays().then(logRelayDiagnostics).catch(console.error);
}); });
</script> </script>
@ -103,13 +110,17 @@
onSearchResults={handleSearchResults} onSearchResults={handleSearchResults}
/> />
{#if $isLoggedIn && !event && searchResults.length === 0}
<EventInput />
{/if}
{#if event} {#if event}
<EventDetails {event} {profile} {searchValue} /> <EventDetails {event} {profile} {searchValue} />
<RelayActions {event} /> <RelayActions {event} />
{#if userPubkey} {#if $isLoggedIn && $userPubkey}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading> <Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox {event} {userPubkey} {userRelayPreference} /> <CommentBox {event} {userRelayPreference} />
</div> </div>
{:else} {:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg"> <div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">

Loading…
Cancel
Save