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

31
src/lib/components/EventDetails.svelte

@ -37,7 +37,36 @@ @@ -37,7 +37,36 @@
let authorDisplayName = $state<string | undefined>(undefined);
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 {

363
src/lib/components/EventInput.svelte

@ -0,0 +1,363 @@ @@ -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 @@ @@ -19,9 +19,6 @@
let searchingRelays = $state(false);
let foundRelays = $state<string[]>([]);
let broadcasting = $state(false);
let broadcastSuccess = $state(false);
let broadcastError = $state<string | null>(null);
let showRelayModal = $state(false);
let relaySearchResults = $state<
Record<string, "pending" | "found" | "notfound">
@ -33,43 +30,6 @@ @@ -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"/>
</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() {
showRelayModal = true;
relaySearchResults = {};
@ -117,17 +77,6 @@ @@ -117,17 +77,6 @@
{@html searchIcon}
Where can I find this event?
</Button>
{#if $ndkInstance?.activeUser}
<Button
on:click={broadcastEvent}
disabled={broadcasting}
class="flex items-center"
>
{@html broadcastIcon}
{broadcasting ? "Broadcasting..." : "Broadcast"}
</Button>
{/if}
</div>
{#if foundRelays.length > 0}
@ -141,23 +90,6 @@ @@ -141,23 +90,6 @@
</div>
{/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">
<span class="font-semibold">Found on:</span>
<div class="flex flex-wrap gap-2 mt-1">

2
src/lib/components/RelayStatus.svelte

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

2
src/lib/consts.ts

@ -6,7 +6,7 @@ export const standardRelays = [ @@ -6,7 +6,7 @@ export const standardRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.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://theforest.gitcitadel.eu',
];

37
src/lib/ndk.ts

@ -397,21 +397,40 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { @@ -397,21 +397,40 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
// Ensure the URL is using wss:// protocol
const secureUrl = ensureSecureWebSocket(url);
// Add connection timeout and error handling
const relay = new NDKRelay(
secureUrl,
NDKRelayAuthPolicies.signIn({ 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
if (ndk.signer && ndk.activeUser) {
const authPolicy = new CustomRelayAuthPolicy(ndk);
relay.on("connect", () => {
console.debug(`[NDK.ts] Relay connected: ${secureUrl}`);
clearTimeout(connectionTimeout);
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;
}
@ -462,7 +481,23 @@ export function initNdk(): NDK { @@ -462,7 +481,23 @@ export function initNdk(): NDK {
// Set up custom authentication policy
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;
}

11
src/lib/stores/authStore.ts

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

Loading…
Cancel
Save