Browse Source

updated and expanded comment box. corrected eventinput displa and updated search. Fixed reactivity problem.

master
silberengel 8 months ago
parent
commit
5dfa4ef2c5
  1. 346
      src/lib/components/CommentBox.svelte
  2. 187
      src/lib/components/EventInput.svelte
  3. 417
      src/lib/components/EventSearch.svelte
  4. 2
      src/lib/consts.ts
  5. 23
      src/lib/ndk.ts
  6. 12
      src/routes/events/+page.svelte

346
src/lib/components/CommentBox.svelte

@ -5,8 +5,20 @@ @@ -5,8 +5,20 @@
import {
getUserMetadata,
toNpub,
type NostrProfile,
} from "$lib/utils/nostrUtils";
// Extend NostrProfile locally to include pubkey for mention search results
type NostrProfile = {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
pubkey?: string;
};
import { activePubkey } from '$lib/ndk';
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
@ -17,6 +29,12 @@ @@ -17,6 +29,12 @@
publishEvent,
navigateToEvent,
} from "$lib/utils/nostrEventService";
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import type NDK from '@nostr-dev-kit/ndk';
import { NDKRelaySet } from '@nostr-dev-kit/ndk';
import { NDKRelay } from '@nostr-dev-kit/ndk';
import { communityRelay } from '$lib/consts';
const props = $props<{
event: NDKEvent;
@ -32,6 +50,75 @@ @@ -32,6 +50,75 @@
let showFallbackRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null);
// Add state for modals and search
let showMentionModal = $state(false);
let showWikilinkModal = $state(false);
let mentionSearch = $state('');
let mentionResults = $state<NostrProfile[]>([]);
let mentionLoading = $state(false);
let wikilinkTarget = $state('');
let wikilinkLabel = $state('');
let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let nip05Search = $state('');
let nip05Results = $state<NostrProfile[]>([]);
let nip05Loading = $state(false);
// Add a cache for pubkeys with kind 1 events on communityRelay
const forestCache: Record<string, boolean> = {};
async function checkForest(pubkey: string): Promise<boolean> {
if (forestCache[pubkey] !== undefined) {
return forestCache[pubkey];
}
// Query the communityRelay for kind 1 events by this pubkey
try {
const relayUrl = communityRelay[0];
const ws = new WebSocket(relayUrl);
return await new Promise((resolve) => {
ws.onopen = () => {
// NIP-01 filter for kind 1 events by pubkey
ws.send(JSON.stringify([
'REQ', 'alexandria-forest', { kinds: [1], authors: [pubkey], limit: 1 }
]));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[2]?.kind === 1) {
forestCache[pubkey] = true;
ws.close();
resolve(true);
} else if (data[0] === 'EOSE') {
forestCache[pubkey] = false;
ws.close();
resolve(false);
}
};
ws.onerror = () => {
forestCache[pubkey] = false;
ws.close();
resolve(false);
};
});
} catch {
forestCache[pubkey] = false;
return false;
}
}
// Track which pubkeys have forest status loaded
let forestStatus: Record<string, boolean> = $state({});
$effect(() => {
// When mentionResults change, check forest status for each
for (const profile of mentionResults) {
if (profile.pubkey && forestStatus[profile.pubkey] === undefined) {
checkForest(profile.pubkey).then((hasForest) => {
forestStatus = { ...forestStatus, [profile.pubkey!]: hasForest };
});
}
}
});
$effect(() => {
if (!activePubkey) {
userProfile = null;
@ -72,9 +159,11 @@ @@ -72,9 +159,11 @@
{ label: "Link", action: () => insertMarkup("[", "](url)") },
{ label: "Image", action: () => insertMarkup("![", "](url)") },
{ label: "Quote", action: () => insertMarkup("> ", "") },
{ label: "List", action: () => insertMarkup("- ", "") },
{ label: "List", action: () => insertMarkup("* ", "") },
{ label: "Numbered List", action: () => insertMarkup("1. ", "") },
{ label: "Hashtag", action: () => insertMarkup("#", "") },
{ label: '@', action: () => { mentionSearch = ''; mentionResults = []; showMentionModal = true; } },
{ label: 'Wikilink', action: () => { showWikilinkModal = true; } },
];
function insertMarkup(prefix: string, suffix: string) {
@ -191,6 +280,183 @@ @@ -191,6 +280,183 @@
isSubmitting = false;
}
}
// Insert at cursor helper
function insertAtCursor(text: string) {
const textarea = document.querySelector('textarea');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
content = content.substring(0, start) + text + content.substring(end);
updatePreview();
setTimeout(() => {
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
}, 0);
}
// Real Nostr profile search logic
async function searchMentions() {
mentionLoading = true;
mentionResults = [];
const searchTerm = mentionSearch.trim();
if (!searchTerm) {
mentionLoading = false;
return;
}
// NIP-05 pattern: user@domain
if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(searchTerm)) {
try {
const [name, domain] = searchTerm.split('@');
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`);
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
// Fetch kind:0 event for pubkey from theforest first
const ndk: NDK = get(ndkInstance);
if (!ndk) {
mentionLoading = false;
return;
}
// Try theforest relay first
const { communityRelay } = await import('$lib/consts');
const forestRelays = communityRelay.map(url => ndk.pool.relays.get(url) ?? ndk.pool.getRelay(url));
let events = await ndk.fetchEvents({ kinds: [0], authors: [pubkey] }, { closeOnEose: true }, new NDKRelaySet(new Set(forestRelays), ndk));
let eventArr = Array.from(events);
if (eventArr.length === 0) {
// Fallback to all relays
const relaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk);
events = await ndk.fetchEvents({ kinds: [0], authors: [pubkey] }, { closeOnEose: true }, relaySet);
eventArr = Array.from(events);
}
if (eventArr.length > 0) {
try {
const event = eventArr[0];
const profileData = JSON.parse(event.content);
mentionResults = [{ ...profileData, pubkey }];
} catch {
mentionResults = [];
}
} else {
mentionResults = [];
}
} else {
mentionResults = [];
}
} catch {
mentionResults = [];
}
mentionLoading = false;
return;
}
// Fallback: search by display name or name
const ndk: NDK = get(ndkInstance);
if (!ndk) {
mentionLoading = false;
return;
}
// Try theforest relay first
const { communityRelay } = await import('$lib/consts');
const forestRelays = communityRelay.map(url => ndk.pool.relays.get(url) ?? ndk.pool.getRelay(url));
let foundProfiles: Record<string, { profile: NostrProfile; created_at: number }> = {};
let relaySet = new NDKRelaySet(new Set(forestRelays), ndk);
let filter = { kinds: [0] };
let sub = ndk.subscribe(filter, { closeOnEose: true }, relaySet);
sub.on('event', (event: any) => {
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || '';
const name = profileData.name || '';
const searchLower = searchTerm.toLowerCase();
if (
displayName.toLowerCase().includes(searchLower) ||
name.toLowerCase().includes(searchLower)
) {
// Deduplicate by pubkey, keep only newest
const pubkey = event.pubkey;
const created_at = event.created_at || 0;
if (!foundProfiles[pubkey] || foundProfiles[pubkey].created_at < created_at) {
foundProfiles[pubkey] = {
profile: { ...profileData, pubkey },
created_at,
};
}
}
} catch {}
});
sub.on('eose', async () => {
const forestResults = Object.values(foundProfiles).map(x => x.profile);
if (forestResults.length > 0) {
mentionResults = forestResults;
mentionLoading = false;
return;
}
// Fallback to all relays
foundProfiles = {};
const allRelays: NDKRelay[] = Array.from(ndk.pool.relays.values());
relaySet = new NDKRelaySet(new Set(allRelays), ndk);
sub = ndk.subscribe(filter, { closeOnEose: true }, relaySet);
sub.on('event', (event: any) => {
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || '';
const name = profileData.name || '';
const searchLower = searchTerm.toLowerCase();
if (
displayName.toLowerCase().includes(searchLower) ||
name.toLowerCase().includes(searchLower)
) {
// Deduplicate by pubkey, keep only newest
const pubkey = event.pubkey;
const created_at = event.created_at || 0;
if (!foundProfiles[pubkey] || foundProfiles[pubkey].created_at < created_at) {
foundProfiles[pubkey] = {
profile: { ...profileData, pubkey },
created_at,
};
}
}
} catch {}
});
sub.on('eose', () => {
mentionResults = Object.values(foundProfiles).map(x => x.profile);
mentionLoading = false;
});
});
}
function selectMention(profile: NostrProfile) {
// Always insert nostr:npub... for the selected profile
const npub = toNpub(profile.pubkey);
if (profile && npub) {
insertAtCursor(`nostr:${npub}`);
}
showMentionModal = false;
mentionSearch = '';
mentionResults = [];
}
function insertWikilink() {
if (!wikilinkTarget.trim()) return;
let markup = '';
if (wikilinkLabel.trim()) {
markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`;
} else {
markup = `[[${wikilinkTarget}]]`;
}
insertAtCursor(markup);
showWikilinkModal = false;
wikilinkTarget = '';
wikilinkLabel = '';
}
// Add a helper to shorten npub
function shortenNpub(npub: string | undefined) {
if (!npub) return '';
return npub.slice(0, 8) + '…' + npub.slice(-4);
}
</script>
<div class="w-full space-y-4">
@ -204,6 +470,80 @@ @@ -204,6 +470,80 @@
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button>
</div>
<!-- Mention Modal -->
{#if showMentionModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-2">Mention User</h3>
<input
type="text"
class="w-full border rounded p-2 mb-2"
placeholder="Search display name or npub..."
bind:value={mentionSearch}
/>
<Button size="xs" color="primary" class="mb-2" onclick={searchMentions} disabled={mentionLoading || !mentionSearch.trim()}>Search</Button>
{#if mentionLoading}
<div>Searching...</div>
{:else if mentionResults.length > 0}
<ul>
{#each mentionResults as profile}
<button type="button" class="w-full text-left cursor-pointer hover:bg-gray-200 p-2 rounded flex items-center gap-3" onclick={() => selectMention(profile)}>
{#if profile.picture}
<img src={profile.picture} alt="Profile" class="w-8 h-8 rounded-full object-cover" />
{/if}
<div class="flex flex-col text-left">
<span class="font-semibold flex items-center gap-1">
{profile.displayName || profile.name || mentionSearch}
{#if profile.pubkey && forestStatus[profile.pubkey]}
<span title="Has posted to the forest">🌲</span>
{/if}
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
<svg class="inline w-4 h-4 text-primary-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
{profile.nip05}
</span>
{/if}
<span class="text-xs text-gray-400 font-mono">{shortenNpub(profile.pubkey)}</span>
</div>
</button>
{/each}
</ul>
{:else}
<div>No results</div>
{/if}
<div class="flex justify-end mt-4">
<Button size="xs" color="alternative" onclick={() => { showMentionModal = false; }}>Cancel</Button>
</div>
</div>
</div>
{/if}
<!-- Wikilink Modal -->
{#if showWikilinkModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-2">Insert Wikilink</h3>
<input
type="text"
class="w-full border rounded p-2 mb-2"
placeholder="Target page (e.g. target page or target-page)"
bind:value={wikilinkTarget}
/>
<input
type="text"
class="w-full border rounded p-2 mb-2"
placeholder="Display text (optional)"
bind:value={wikilinkLabel}
/>
<div class="flex justify-end gap-2 mt-4">
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button>
<Button size="xs" color="alternative" on:click={() => { showWikilinkModal = false; }}>Cancel</Button>
</div>
</div>
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Textarea
@ -214,7 +554,7 @@ @@ -214,7 +554,7 @@
class="w-full"
/>
</div>
<div class="prose dark:prose-invert max-w-none p-4 border rounded-lg">
<div class="prose dark:prose-invert max-w-none p-4 border border-gray-300 dark:border-gray-700 rounded-lg">
{@html preview}
</div>
</div>

187
src/lib/components/EventInput.svelte

@ -1,7 +1,8 @@ @@ -1,7 +1,8 @@
<script lang='ts'>
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag, validate30040EventSet, get30040EventDescription, analyze30040Event, get30040FixGuidance } from '$lib/utils/event_input_utils';
import { get } from 'svelte/store';
import { ndkInstance, activePubkey } from '$lib/ndk';
import { ndkInstance } from '$lib/ndk';
import { userPubkey } from '$lib/stores/authStore';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { prefixNostrAddresses } from '$lib/utils/nostrUtils';
@ -24,7 +25,7 @@ @@ -24,7 +25,7 @@
let dTagError = $state('');
let lastPublishedEventId = $state<string | null>(null);
$effect(() => {
pubkey = get(activePubkey);
pubkey = get(userPubkey);
});
/**
@ -344,100 +345,98 @@ @@ -344,100 +345,98 @@
}
</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}
{#if kind === 30040}
<div class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded">
<strong>30040 - Publication Index:</strong> {get30040EventDescription()}
</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}
<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 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>
</div>
<div>
<label class='block font-medium mb-1' for='event-content'>Content</label>
<textarea
id='event-content'
bind:value={content}
oninput={handleContentInput}
placeholder='Content (start with a header for the title)'
class='textarea textarea-bordered w-full h-40'
required
></textarea>
</div>
<div>
<label class='block font-medium mb-1' for='event-title'>Title</label>
<input
type='text'
id='event-title'
bind:value={title}
oninput={handleTitleInput}
placeholder='Title (auto-filled from header)'
class='input input-bordered w-full'
/>
</div>
<div>
<label class='block font-medium mb-1' for='event-d-tag'>d-tag</label>
<input
type='text'
id='event-d-tag'
bind:value={dTag}
oninput={handleDTagInput}
placeholder='d-tag (auto-generated from title)'
class='input input-bordered w-full'
required={requiresDTag(kind)}
/>
{#if dTagError}
<div class='text-red-600 text-sm mt-1'>{dTagError}</div>
{/if}
</div>
<div class='flex justify-end'>
<button type='submit' class='btn btn-primary border border-primary-600 px-4 py-2' disabled={loading}>Publish</button>
</div>
{#if loading}
<span class='ml-2 text-gray-500'>Publishing...</span>
{/if}
{#if error}
<div class='mt-2 text-red-600'>{error}</div>
{#if kind === 30040}
<div class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded">
<strong>30040 - Publication Index:</strong> {get30040EventDescription()}
</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>
<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>
{/if}
{/each}
<div class='flex justify-end'>
<button type='button' class='btn btn-primary btn-sm border border-primary-600 px-3 py-1' onclick={addTag}>Add Tag</button>
</div>
</div>
</div>
<div>
<label class='block font-medium mb-1' for='event-content'>Content</label>
<textarea
id='event-content'
bind:value={content}
oninput={handleContentInput}
placeholder='Content (start with a header for the title)'
class='textarea textarea-bordered w-full h-40'
required
></textarea>
</div>
<div>
<label class='block font-medium mb-1' for='event-title'>Title</label>
<input
type='text'
id='event-title'
bind:value={title}
oninput={handleTitleInput}
placeholder='Title (auto-filled from header)'
class='input input-bordered w-full'
/>
</div>
<div>
<label class='block font-medium mb-1' for='event-d-tag'>d-tag</label>
<input
type='text'
id='event-d-tag'
bind:value={dTag}
oninput={handleDTagInput}
placeholder='d-tag (auto-generated from title)'
class='input input-bordered w-full'
required={requiresDTag(kind)}
/>
{#if dTagError}
<div class='text-red-600 text-sm mt-1'>{dTagError}</div>
{/if}
</div>
<div class='flex justify-end'>
<button type='submit' class='btn btn-primary border border-primary-600 px-4 py-2' disabled={loading}>Publish</button>
</div>
{#if loading}
<span class='ml-2 text-gray-500'>Publishing...</span>
{/if}
{#if error}
<div class='mt-2 text-red-600'>{error}</div>
{/if}
{#if success}
<div class='mt-2 text-green-600'>{success}</div>
<div class='text-xs text-gray-500'>Relays: {publishedRelays.join(', ')}</div>
{#if lastPublishedEventId}
<div class='mt-2 text-green-700'>
Event ID: <span class='font-mono'>{lastPublishedEventId}</span>
<a
href={'/events?id=' + lastPublishedEventId}
class='text-primary-600 dark:text-primary-500 hover:underline ml-2'
>
View your event
</a>
</div>
{/if}
</form>
</div>
{/if}
{/if}
</form>
</div>

417
src/lib/components/EventSearch.svelte

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import RelayDisplay from "./RelayDisplay.svelte";
import { getActiveRelays } from "$lib/ndk";
import { NDKRelaySet } from "@nostr-dev-kit/ndk";
const {
loading,
@ -38,6 +38,8 @@ @@ -38,6 +38,8 @@
);
let foundEvent = $state<NDKEvent | null>(null);
let searching = $state(false);
let activeSub: any = null;
let foundProfiles: NDKEvent[] = [];
$effect(() => {
if (searchValue) {
@ -47,7 +49,7 @@ @@ -47,7 +49,7 @@
$effect(() => {
if (dTagValue) {
searchByDTag(dTagValue);
searchBySubscription('d', dTagValue);
}
});
@ -55,66 +57,231 @@ @@ -55,66 +57,231 @@
foundEvent = event;
});
async function searchByDTag(dTag: string) {
async function searchBySubscription(searchType: 'd' | 't' | 'n', searchTerm: string) {
localError = null;
searching = true;
if (onLoadingChange) { onLoadingChange(true); }
// Convert d-tag to lowercase for consistent searching
const normalizedDTag = dTag.toLowerCase();
const normalizedSearchTerm = searchTerm.toLowerCase();
const ndk = $ndkInstance;
if (!ndk) {
localError = 'NDK not initialized';
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
}
try {
console.log("[Events] Searching for events with d-tag:", normalizedDTag);
const ndk = $ndkInstance;
if (!ndk) {
localError = "NDK not initialized";
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
// Use all relays from the NDK pool
const relaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let firstOrderEvents: NDKEvent[] = [];
let secondOrderEvents: NDKEvent[] = [];
let tTagEvents: NDKEvent[] = [];
let eventIds = new Set<string>();
let eventAddresses = new Set<string>();
let foundProfiles: NDKEvent[] = [];
// Helper function to clean up subscription and timeout
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (activeSub) {
activeSub.stop();
activeSub = null;
}
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
};
// Helper function to check if a profile field matches the search term
const fieldMatches = (field: string) => {
if (!field) return false;
const fieldLower = field.toLowerCase();
const searchLower = normalizedSearchTerm.toLowerCase();
if (fieldLower === searchLower) return true;
if (fieldLower.includes(searchLower)) return true;
const words = fieldLower.split(/\s+/);
return words.some(word => word.includes(searchLower));
};
// Set a timeout to force completion after 15 seconds
timeoutId = setTimeout(() => {
console.log(`[Events] ${searchType.toUpperCase()}-tag search timeout reached`);
if (searchType === 'n' && foundProfiles.length === 0) {
localError = `No profiles found matching: ${searchTerm} (search timed out)`;
onSearchResults([], [], [], new Set(), new Set());
} else if (searchType === 'd' && firstOrderEvents.length === 0) {
localError = `No events found with d-tag: ${searchTerm} (search timed out)`;
onSearchResults([], [], [], new Set(), new Set());
} else if (searchType === 't' && tTagEvents.length === 0) {
localError = `No events found with t-tag: ${searchTerm} (search timed out)`;
onSearchResults([], [], [], new Set(), new Set());
}
cleanup();
}, 15000);
let filter: any;
let subscriptionType: string;
switch (searchType) {
case 'd':
filter = { "#d": [normalizedSearchTerm] };
subscriptionType = 'd-tag';
break;
case 't':
filter = { "#t": [normalizedSearchTerm] };
subscriptionType = 't-tag';
break;
case 'n':
filter = { kinds: [0] };
subscriptionType = 'profile';
break;
}
const filter = { "#d": [normalizedDTag] };
const relaySet = getActiveRelays(ndk);
console.log(`[Events] Starting ${subscriptionType} search for:`, normalizedSearchTerm);
// Fetch multiple events with the same d-tag
const events = await ndk.fetchEvents(
filter,
{ closeOnEose: true },
relaySet,
);
const eventArray = Array.from(events);
// Subscribe to events
const sub = ndk.subscribe(
filter,
{ closeOnEose: true },
relaySet
);
if (eventArray.length === 0) {
localError = `No events found with d-tag: ${normalizedDTag}`;
onSearchResults([], [], [], new Set(), new Set());
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
sub.on('event', (event) => {
try {
if (searchType === 'n') {
// Profile search logic
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || '';
const name = profileData.name || '';
const nip05 = profileData.nip05 || '';
if (fieldMatches(displayName) || fieldMatches(name) || fieldMatches(nip05.split('@')[0])) {
foundProfiles = [...foundProfiles, event];
onSearchResults(foundProfiles, [], [], new Set(foundProfiles.map(p => p.id)), new Set());
}
} else {
// d-tag and t-tag search logic
if (event.kind === 7) return; // Skip emoji reactions
if (searchType === 'd') {
firstOrderEvents = [...firstOrderEvents, event];
// Collect event IDs and addresses for second-order search
if (event.id) {
eventIds.add(event.id);
}
const aTags = getMatchingTags(event, "a");
aTags.forEach((tag: string[]) => {
if (tag[1]) {
eventAddresses.add(tag[1]);
}
});
} else if (searchType === 't') {
tTagEvents = [...tTagEvents, event];
}
}
} catch (e) {
// Invalid JSON or other error, skip
}
});
// Collect all event IDs and addresses for second-order search
const eventIds = new Set<string>();
const eventAddresses = new Set<string>();
sub.on('eose', () => {
console.log(`[Events] ${subscriptionType} search EOSE received`);
eventArray.forEach(event => {
if (event.id) {
eventIds.add(event.id);
if (searchType === 'n') {
if (foundProfiles.length === 0) {
localError = `No profiles found matching: ${searchTerm}`;
onSearchResults([], [], [], new Set(), new Set());
} else {
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of foundProfiles) {
const pubkey = event.pubkey;
const created_at = event.created_at || 0;
if (!deduped[pubkey] || deduped[pubkey].created_at < created_at) {
deduped[pubkey] = { event, created_at };
}
}
const dedupedProfiles = Object.values(deduped).map(x => x.event);
onSearchResults(dedupedProfiles, [], [], new Set(dedupedProfiles.map(p => p.id)), new Set());
}
// Add a-tag addresses (kind:pubkey:d)
const aTags = getMatchingTags(event, "a");
aTags.forEach((tag: string[]) => {
if (tag[1]) {
eventAddresses.add(tag[1]);
} else if (searchType === 'd') {
if (firstOrderEvents.length === 0) {
localError = `No events found with d-tag: ${searchTerm}`;
onSearchResults([], [], [], new Set(), new Set());
} else {
// Deduplicate by kind, pubkey, and d-tag, keep only newest event for each combination
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of firstOrderEvents) {
const dTag = getMatchingTags(event, 'd')[0]?.[1] || '';
const key = `${event.kind}:${event.pubkey}:${dTag}`;
const created_at = event.created_at || 0;
if (!deduped[key] || deduped[key].created_at < created_at) {
deduped[key] = { event, created_at };
}
}
});
});
const dedupedEvents = Object.values(deduped).map(x => x.event);
onSearchResults(dedupedEvents, [], [], eventIds, eventAddresses);
localError = `Found ${dedupedEvents.length} unique d-tag events. Searching for second-order results...`;
// Perform second-order search in background
firstOrderEvents = dedupedEvents;
performSecondOrderSearch();
}
} else if (searchType === 't') {
if (tTagEvents.length === 0) {
localError = `No events found with t-tag: ${searchTerm}`;
onSearchResults([], [], [], new Set(), new Set());
} else {
console.log("[Events] T-tag search completed, found", tTagEvents.length, "events");
onSearchResults([], [], tTagEvents, new Set(), new Set());
}
}
cleanup();
});
// Helper function to perform second-order search for d-tag searches
async function performSecondOrderSearch() {
if (eventIds.size === 0 && eventAddresses.size === 0) {
// No references to search for, just search for t-tag events
console.log("[Events] No references found, searching for t-tag events only");
try {
const tTagFilter = { '#t': [normalizedSearchTerm] };
const tTagEventsSet = await ndk.fetchEvents(
tTagFilter,
{ closeOnEose: true },
relaySet,
);
const tTagEvents = Array.from(tTagEventsSet).filter(e =>
e.kind !== 7 &&
!firstOrderEvents.some(fe => fe.id === e.id)
);
// Search for second-order events that reference the original events
const secondOrderEvents = new Set<NDKEvent>();
console.log("[Events] T-tag search completed:", {
firstOrder: firstOrderEvents.length,
tTag: tTagEvents.length
});
// Clear the "searching" message
localError = null;
onSearchResults(firstOrderEvents, [], tTagEvents, eventIds, eventAddresses);
} catch (err) {
console.error("[Events] Error in t-tag search:", err);
localError = null;
onSearchResults(firstOrderEvents, [], [], eventIds, eventAddresses);
}
return;
}
console.log("[Events] Starting second-order search...");
if (eventIds.size > 0 || eventAddresses.size > 0) {
console.log("[Events] Searching for second-order events...");
try {
// Search for events with e tags referencing the original events
if (eventIds.size > 0) {
const eTagFilter = { "#e": Array.from(eventIds) };
@ -123,7 +290,11 @@ @@ -123,7 +290,11 @@
{ closeOnEose: true },
relaySet,
);
eTagEvents.forEach(event => secondOrderEvents.add(event));
eTagEvents.forEach(event => {
if (event.kind !== 7) { // Skip emoji reactions
secondOrderEvents.push(event);
}
});
}
// Search for events with a tags referencing the original events
@ -134,35 +305,36 @@ @@ -134,35 +305,36 @@
{ closeOnEose: true },
relaySet,
);
aTagEvents.forEach(event => secondOrderEvents.add(event));
aTagEvents.forEach(event => {
if (event.kind !== 7) { // Skip emoji reactions
secondOrderEvents.push(event);
}
});
}
// Search for events with content containing nevent/naddr/note references
// This is a more complex search that requires fetching recent events and checking content
// Limit the search to recent events to avoid performance issues
const recentEvents = await ndk.fetchEvents(
{
limit: 500, // Reduced limit for better performance
since: Math.floor(Date.now() / 1000) - (7 * 24 * 60 * 60) // Last 7 days
limit: 10000,
since: Math.floor(Date.now() / 1000) - (30 * 24 * 60 * 60) // Last 30 days
},
{ closeOnEose: true },
relaySet,
);
recentEvents.forEach(event => {
if (event.content) {
// Check for nevent references with more precise matching
if (event.content && event.kind !== 7) {
// Check for nevent references
eventIds.forEach(id => {
// Look for complete nevent references
const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, 'i');
const matches = event.content.match(neventPattern);
if (matches) {
// Verify the nevent contains the event ID
matches.forEach(match => {
try {
const decoded = nip19.decode(match);
if (decoded && decoded.type === 'nevent' && decoded.data.id === id) {
secondOrderEvents.add(event);
secondOrderEvents.push(event);
}
} catch (e) {
// Invalid nevent, skip
@ -171,19 +343,18 @@ @@ -171,19 +343,18 @@
}
});
// Check for naddr references with more precise matching
// Check for naddr references
eventAddresses.forEach(address => {
const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, 'i');
const matches = event.content.match(naddrPattern);
if (matches) {
// Verify the naddr contains the address
matches.forEach(match => {
try {
const decoded = nip19.decode(match);
if (decoded && decoded.type === 'naddr') {
const decodedAddress = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`;
if (decodedAddress === address) {
secondOrderEvents.add(event);
secondOrderEvents.push(event);
}
}
} catch (e) {
@ -193,17 +364,16 @@ @@ -193,17 +364,16 @@
}
});
// Check for note references (event IDs) with more precise matching
// Check for note references
eventIds.forEach(id => {
const notePattern = new RegExp(`note1[a-z0-9]{50,}`, 'i');
const matches = event.content.match(notePattern);
if (matches) {
// Verify the note contains the event ID
matches.forEach(match => {
try {
const decoded = nip19.decode(match);
if (decoded && decoded.type === 'note' && decoded.data === id) {
secondOrderEvents.add(event);
secondOrderEvents.push(event);
}
} catch (e) {
// Invalid note, skip
@ -213,58 +383,64 @@ @@ -213,58 +383,64 @@
});
}
});
}
// Combine first-order and second-order events
const allEvents = [...eventArray, ...Array.from(secondOrderEvents)];
// Remove duplicates based on event ID
const uniqueEvents = new Map<string, NDKEvent>();
allEvents.forEach(event => {
if (event.id) {
uniqueEvents.set(event.id, event);
}
});
const finalEvents = Array.from(uniqueEvents.values());
// Separate first-order and second-order events
const firstOrderSet = new Set(eventArray.map(e => e.id));
const firstOrder = finalEvents.filter(e => firstOrderSet.has(e.id));
const secondOrder = finalEvents.filter(e => !firstOrderSet.has(e.id));
// Remove kind 7 (emoji reactions) from both first-order and second-order results
const filteredFirstOrder = firstOrder.filter(e => e.kind !== 7);
const filteredSecondOrder = secondOrder.filter(e => e.kind !== 7);
// --- t: search ---
// Search for events with a matching t-tag (topic/tag)
const tTagFilter = { '#t': [normalizedDTag] };
const tTagEventsSet = await ndk.fetchEvents(
tTagFilter,
{ closeOnEose: true },
relaySet,
);
// Remove any events already in first or second order
const tTagEvents = Array.from(tTagEventsSet).filter(e =>
e.kind !== 7 &&
!firstOrderSet.has(e.id) &&
!filteredSecondOrder.some(se => se.id === e.id)
);
// Remove duplicates from second-order events
const uniqueSecondOrder = new Map<string, NDKEvent>();
secondOrderEvents.forEach(event => {
if (event.id) {
uniqueSecondOrder.set(event.id, event);
}
});
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values());
onSearchResults(filteredFirstOrder, filteredSecondOrder, tTagEvents, eventIds, eventAddresses);
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
} catch (err) {
console.error("[Events] Error searching by d-tag:", err);
onSearchResults([], [], [], new Set(), new Set());
searching = false;
if (onLoadingChange) { onLoadingChange(false); }
return;
// Remove any events already in firstOrderEvents (d-tag section)
const firstOrderIds = new Set(firstOrderEvents.map(e => e.id));
deduplicatedSecondOrder = deduplicatedSecondOrder.filter(e => !firstOrderIds.has(e.id));
// Search for t-tag events
const tTagFilter = { '#t': [normalizedSearchTerm] };
const tTagEventsSet = await ndk.fetchEvents(
tTagFilter,
{ closeOnEose: true },
relaySet,
);
// Remove any events already in first or second order
const firstOrderSet = new Set(firstOrderEvents.map(e => e.id));
const secondOrderSet = new Set(deduplicatedSecondOrder.map(e => e.id));
const tTagEvents = Array.from(tTagEventsSet).filter(e =>
e.kind !== 7 &&
!firstOrderSet.has(e.id) &&
!secondOrderSet.has(e.id)
);
console.log("[Events] Second-order search completed:", {
firstOrder: firstOrderEvents.length,
secondOrder: deduplicatedSecondOrder.length,
tTag: tTagEvents.length
});
// Clear the "searching" message
localError = null;
// Update results with second-order and t-tag events
onSearchResults(firstOrderEvents, deduplicatedSecondOrder, tTagEvents, eventIds, eventAddresses);
} catch (err) {
console.error("[Events] Error in second-order search:", err);
// Clear the "searching" message
localError = null;
// Return first-order results even if second-order search fails
onSearchResults(firstOrderEvents, [], [], eventIds, eventAddresses);
}
}
if (activeSub) { activeSub.stop(); }
activeSub = sub;
}
async function searchEvent(
clearInput: boolean = true,
queryOverride?: string,
@ -297,6 +473,24 @@ @@ -297,6 +473,24 @@
}
}
// Check if this is a t-tag search
if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await searchBySubscription('t', searchTerm);
return;
}
}
// Check if this is an npub search
if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await searchBySubscription('n', searchTerm);
return;
}
}
// Only update the URL if this is a manual search
if (clearInput) {
const encoded = encodeURIComponent(query);
@ -471,6 +665,9 @@ @@ -471,6 +665,9 @@
localError = null;
foundEvent = null;
relayStatuses = {};
if (activeSub) { activeSub.stop(); activeSub = null; }
foundProfiles = [];
onSearchResults([], [], [], new Set(), new Set());
if (onClear) {
onClear();
}
@ -481,7 +678,7 @@ @@ -481,7 +678,7 @@
<div class="flex gap-2 items-center">
<Input
bind:value={searchQuery}
placeholder="Enter event ID, nevent, naddr, or d:tag-name..."
placeholder="Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username..."
class="flex-grow"
onkeydown={(e: KeyboardEvent) => e.key === "Enter" && searchEvent(true)}
/>

2
src/lib/consts.ts

@ -23,8 +23,8 @@ export const fallbackRelays = [ @@ -23,8 +23,8 @@ export const fallbackRelays = [
"wss://indexer.coracle.social",
"wss://relay.noswhere.com",
"wss://aggr.nostr.land",
"wss://nostr.wine",
"wss://nostr.land",
"wss://nostr.wine",
"wss://nostr.sovbit.host",
"wss://freelay.sovbit.host",
"wss://nostr21.com",

23
src/lib/ndk.ts

@ -15,6 +15,7 @@ import { @@ -15,6 +15,7 @@ import {
anonymousRelays,
} from "./consts";
import { feedType } from "./stores";
import { userPubkey } from '$lib/stores/authStore';
export const ndkInstance: Writable<NDK> = writable();
@ -435,21 +436,11 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { @@ -435,21 +436,11 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
}
export function getActiveRelays(ndk: NDK): NDKRelaySet {
// Use anonymous relays if user is not signed in
const isSignedIn = ndk.signer && ndk.activeUser;
const relays = isSignedIn ? standardRelays : anonymousRelays;
return get(feedType) === FeedType.UserRelays
? new NDKRelaySet(
new Set(
get(inboxRelays).map((relay) => createRelayWithAuth(relay, ndk)),
),
ndk,
)
: new NDKRelaySet(
new Set(relays.map((relay) => createRelayWithAuth(relay, ndk))),
ndk,
);
// Use all relays currently in the NDK pool
return new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values())),
ndk,
);
}
/**
@ -522,6 +513,7 @@ export async function loginWithExtension( @@ -522,6 +513,7 @@ export async function loginWithExtension(
}
activePubkey.set(signerUser.pubkey);
userPubkey.set(signerUser.pubkey);
const [persistedInboxes, persistedOutboxes] =
getPersistedRelays(signerUser);
@ -561,6 +553,7 @@ export function logout(user: NDKUser): void { @@ -561,6 +553,7 @@ export function logout(user: NDKUser): void {
clearLogin();
clearPersistedRelays(user);
activePubkey.set(null);
userPubkey.set(null);
ndkSignedIn.set(false);
ndkInstance.set(initNdk()); // Re-initialize with anonymous instance
}

12
src/routes/events/+page.svelte

@ -12,7 +12,6 @@ @@ -12,7 +12,6 @@
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';
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte';
import { neventEncode, naddrEncode } from '$lib/utils';
@ -172,9 +171,6 @@ @@ -172,9 +171,6 @@
});
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
@ -183,7 +179,7 @@ @@ -183,7 +179,7 @@
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4 mx-auto">
<div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading>
</div>
@ -465,5 +461,11 @@ @@ -465,5 +461,11 @@
</div>
</div>
{/if}
{#if !event && searchResults.length === 0 && secondOrderResults.length === 0 && tTagResults.length === 0 && !searchValue && !dTagValue}
<div class="mt-8">
<EventInput />
</div>
{/if}
</main>
</div>

Loading…
Cancel
Save