Browse Source

Event Search revamped

master
silberengel 8 months ago
parent
commit
212563aae5
  1. 422
      src/lib/components/CommentBox.svelte
  2. 27
      src/lib/components/EventDetails.svelte
  3. 30
      src/lib/components/EventInput.svelte
  4. 916
      src/lib/components/EventSearch.svelte
  5. 57
      src/lib/components/LoginModal.svelte
  6. 12
      src/lib/components/Modal.svelte
  7. 67
      src/lib/components/PublicationFeed.svelte
  8. 2
      src/lib/components/PublicationHeader.svelte
  9. 10
      src/lib/components/PublicationSection.svelte
  10. 27
      src/lib/components/RelayActions.svelte
  11. 45
      src/lib/components/cards/ProfileHeader.svelte
  12. 114
      src/lib/components/util/CardActions.svelte
  13. 17
      src/lib/components/util/Profile.svelte
  14. 80
      src/lib/components/util/ViewPublicationLink.svelte
  15. 3
      src/lib/consts.ts
  16. 2
      src/lib/ndk.ts
  17. 27
      src/lib/snippets/UserSnippets.svelte
  18. 0
      src/lib/stores/authStore.Svelte.ts
  19. 65
      src/lib/utils/community_checker.ts
  20. 3
      src/lib/utils/event_input_utils.ts
  21. 143
      src/lib/utils/event_search.ts
  22. 132
      src/lib/utils/indexEventCache.ts
  23. 1
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  24. 9
      src/lib/utils/mime.ts
  25. 11
      src/lib/utils/nostrEventService.ts
  26. 40
      src/lib/utils/nostrUtils.ts
  27. 233
      src/lib/utils/profile_search.ts
  28. 3
      src/lib/utils/relayDiagnostics.ts
  29. 105
      src/lib/utils/searchCache.ts
  30. 121
      src/lib/utils/search_constants.ts
  31. 69
      src/lib/utils/search_types.ts
  32. 25
      src/lib/utils/search_utility.ts
  33. 104
      src/lib/utils/search_utils.ts
  34. 651
      src/lib/utils/subscription_search.ts
  35. 405
      src/routes/events/+page.svelte

422
src/lib/components/CommentBox.svelte

@ -1,24 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Button, Textarea, Alert } from "flowbite-svelte"; import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
getUserMetadata, import { searchProfiles } from "$lib/utils/search_utility";
toNpub, import type { NostrProfile } from "$lib/utils/search_utility";
} 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 { activePubkey } from '$lib/ndk';
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { import {
@ -35,6 +22,8 @@
import { NDKRelaySet } from '@nostr-dev-kit/ndk'; import { NDKRelaySet } from '@nostr-dev-kit/ndk';
import { NDKRelay } from '@nostr-dev-kit/ndk'; import { NDKRelay } from '@nostr-dev-kit/ndk';
import { communityRelay } from '$lib/consts'; import { communityRelay } from '$lib/consts';
import { tick } from 'svelte';
import { goto } from "$app/navigation";
const props = $props<{ const props = $props<{
event: NDKEvent; event: NDKEvent;
@ -59,70 +48,24 @@
let wikilinkTarget = $state(''); let wikilinkTarget = $state('');
let wikilinkLabel = $state(''); let wikilinkLabel = $state('');
let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null; let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let nip05Search = $state(''); let mentionSearchInput: HTMLInputElement | undefined;
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({});
// Reset modal state when it opens/closes
$effect(() => { $effect(() => {
// When mentionResults change, check forest status for each if (showMentionModal) {
for (const profile of mentionResults) { // Reset search when modal opens
if (profile.pubkey && forestStatus[profile.pubkey] === undefined) { mentionSearch = '';
checkForest(profile.pubkey).then((hasForest) => { mentionResults = [];
forestStatus = { ...forestStatus, [profile.pubkey!]: hasForest }; mentionLoading = false;
}); // Focus the search input after a brief delay to ensure modal is rendered
} setTimeout(() => {
} mentionSearchInput?.focus();
}); }, 100);
} else {
$effect(() => { // Reset search when modal closes
if (!activePubkey) { mentionSearch = '';
userProfile = null; mentionResults = [];
error = null; mentionLoading = false;
} }
}); });
@ -198,7 +141,6 @@
content = ""; content = "";
preview = ""; preview = "";
error = null; error = null;
success = null;
showOtherRelays = false; showOtherRelays = false;
showFallbackRelays = false; showFallbackRelays = false;
} }
@ -208,7 +150,7 @@
.replace(/\*\*(.*?)\*\*/g, "$1") .replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/_(.*?)_/g, "$1") .replace(/_(.*?)_/g, "$1")
.replace(/~~(.*?)~~/g, "$1") .replace(/~~(.*?)~~/g, "$1")
.replace(/\[(.*?)\]\(.*?\)/g, "$1") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/!\[(.*?)\]\(.*?\)/g, "$1") .replace(/!\[(.*?)\]\(.*?\)/g, "$1")
.replace(/^>\s*/gm, "") .replace(/^>\s*/gm, "")
.replace(/^[-*]\s*/gm, "") .replace(/^[-*]\s*/gm, "")
@ -271,175 +213,101 @@
showFallbackRelays = true; showFallbackRelays = true;
error = "Failed to publish to other relays. Would you like to try the fallback relays?"; error = "Failed to publish to other relays. Would you like to try the fallback relays?";
} else { } else {
error = result.error || "Failed to publish to any relays. Please try again later."; error = "Failed to publish comment. Please try again later.";
} }
} }
} catch (e) { } catch (e: unknown) {
error = e instanceof Error ? e.message : "An error occurred"; console.error('Error publishing comment:', e);
error = e instanceof Error ? e.message : 'An unexpected error occurred';
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
} }
// Insert at cursor helper // Add a helper to shorten npub
function insertAtCursor(text: string) { function shortenNpub(npub: string | undefined) {
const textarea = document.querySelector('textarea'); if (!npub) return '';
return npub.slice(0, 8) + '…' + npub.slice(-4);
}
async function insertAtCursor(text: string) {
const textarea = document.querySelector("textarea");
if (!textarea) return; if (!textarea) return;
const start = textarea.selectionStart; const start = textarea.selectionStart;
const end = textarea.selectionEnd; const end = textarea.selectionEnd;
content = content.substring(0, start) + text + content.substring(end); content = content.substring(0, start) + text + content.substring(end);
updatePreview(); updatePreview();
setTimeout(() => {
// Wait for DOM updates to complete
await tick();
textarea.focus(); textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length; textarea.selectionStart = textarea.selectionEnd = start + text.length;
}, 0);
} }
// Real Nostr profile search logic // Add mention search functionality using centralized search utility
let communityStatus: Record<string, boolean> = $state({});
let isSearching = $state(false);
async function searchMentions() { async function searchMentions() {
mentionLoading = true; if (!mentionSearch.trim()) {
mentionResults = []; mentionResults = [];
const searchTerm = mentionSearch.trim(); communityStatus = {};
if (!searchTerm) {
mentionLoading = false;
return; return;
} }
// NIP-05 pattern: user@domain
if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(searchTerm)) { // Prevent multiple concurrent searches
try { if (isSearching) {
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; return;
} }
// Try theforest relay first
const { communityRelay } = await import('$lib/consts'); // Set loading state
const forestRelays = communityRelay.map(url => ndk.pool.relays.get(url) ?? ndk.pool.getRelay(url)); mentionLoading = true;
let events = await ndk.fetchEvents({ kinds: [0], authors: [pubkey] }, { closeOnEose: true }, new NDKRelaySet(new Set(forestRelays), ndk)); isSearching = true;
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 { try {
const event = eventArr[0]; const result = await searchProfiles(mentionSearch.trim());
const profileData = JSON.parse(event.content); mentionResults = result.profiles;
mentionResults = [{ ...profileData, pubkey }]; communityStatus = result.Status;
} catch { } catch (error) {
console.error('Error searching mentions:', error);
mentionResults = []; mentionResults = [];
} communityStatus = {};
} else { } finally {
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; mentionLoading = false;
return; isSearching = false;
}
// 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) { function selectMention(profile: NostrProfile) {
// Always insert nostr:npub... for the selected profile let mention = '';
if (profile.pubkey) {
try {
const npub = toNpub(profile.pubkey); const npub = toNpub(profile.pubkey);
if (profile && npub) { if (npub) {
insertAtCursor(`nostr:${npub}`); mention = `nostr:${npub}`;
} else {
// If toNpub fails, fallback to pubkey
mention = `nostr:${profile.pubkey}`;
} }
} catch (e) {
console.error('Error in toNpub:', e);
// Fallback to pubkey if conversion fails
mention = `nostr:${profile.pubkey}`;
}
} else {
console.warn('No pubkey in profile, falling back to display name');
mention = `@${profile.displayName || profile.name}`;
}
insertAtCursor(mention);
showMentionModal = false; showMentionModal = false;
mentionSearch = ''; mentionSearch = '';
mentionResults = []; mentionResults = [];
} }
function insertWikilink() { function insertWikilink() {
if (!wikilinkTarget.trim()) return;
let markup = ''; let markup = '';
if (wikilinkLabel.trim()) { if (wikilinkLabel.trim()) {
markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`; markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`;
@ -452,10 +320,11 @@
wikilinkLabel = ''; wikilinkLabel = '';
} }
// Add a helper to shorten npub function handleViewComment() {
function shortenNpub(npub: string | undefined) { if (success?.eventId) {
if (!npub) return ''; const nevent = nip19.neventEncode({ id: success.eventId });
return npub.slice(0, 8) + '…' + npub.slice(-4); goto(`/events?id=${encodeURIComponent(nevent)}`);
}
} }
</script> </script>
@ -471,32 +340,70 @@
</div> </div>
<!-- Mention Modal --> <!-- Mention Modal -->
{#if showMentionModal} <Modal
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40"> class="modal-leather"
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md"> title="Mention User"
<h3 class="text-lg font-semibold mb-2">Mention User</h3> bind:open={showMentionModal}
autoclose
outsideclose
size="sm"
>
<div class="space-y-4">
<div class="flex gap-2">
<input <input
type="text" type="text"
class="w-full border rounded p-2 mb-2" placeholder="Search display name, name, NIP-05, or npub..."
placeholder="Search display name or npub..."
bind:value={mentionSearch} bind:value={mentionSearch}
bind:this={mentionSearchInput}
onkeydown={(e) => {
if (e.key === 'Enter' && mentionSearch.trim() && !isSearching) {
searchMentions();
}
}}
class="flex-1 rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500 p-2.5"
/> />
<Button size="xs" color="primary" class="mb-2" onclick={searchMentions} disabled={mentionLoading || !mentionSearch.trim()}>Search</Button> <Button
size="xs"
color="primary"
onclick={(e: Event) => {
e.preventDefault();
e.stopPropagation();
searchMentions();
}}
disabled={isSearching || !mentionSearch.trim()}
>
{#if isSearching}
Searching...
{:else}
Search
{/if}
</Button>
</div>
{#if mentionLoading} {#if mentionLoading}
<div>Searching...</div> <div class="text-center py-4">Searching...</div>
{:else if mentionResults.length > 0} {:else if mentionResults.length > 0}
<ul> <div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<ul class="space-y-1 p-2">
{#each mentionResults as profile} {#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)}> <button type="button" class="w-full text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-2 rounded flex items-center gap-3" onclick={() => selectMention(profile)}>
{#if profile.pubkey && communityStatus[profile.pubkey]}
<div class="flex-shrink-0 w-6 h-6 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-4 h-4 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-6 h-6"></div>
{/if}
{#if profile.picture} {#if profile.picture}
<img src={profile.picture} alt="Profile" class="w-8 h-8 rounded-full object-cover" /> <img src={profile.picture} alt="Profile" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0"></div>
{/if} {/if}
<div class="flex flex-col text-left"> <div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold flex items-center gap-1"> <span class="font-semibold truncate">
{profile.displayName || profile.name || mentionSearch} {profile.displayName || profile.name || mentionSearch}
{#if profile.pubkey && forestStatus[profile.pubkey]}
<span title="Has posted to the forest">🌲</span>
{/if}
</span> </span>
{#if profile.nip05} {#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1"> <span class="text-xs text-gray-500 flex items-center gap-1">
@ -504,47 +411,48 @@
{profile.nip05} {profile.nip05}
</span> </span>
{/if} {/if}
<span class="text-xs text-gray-400 font-mono">{shortenNpub(profile.pubkey)}</span> <span class="text-xs text-gray-400 font-mono truncate">{shortenNpub(profile.pubkey)}</span>
</div> </div>
</button> </button>
{/each} {/each}
</ul> </ul>
</div>
{:else if mentionSearch.trim()}
<div class="text-center py-4 text-gray-500">No results found</div>
{:else} {:else}
<div>No results</div> <div class="text-center py-4 text-gray-500">Enter a search term to find users</div>
{/if} {/if}
<div class="flex justify-end mt-4">
<Button size="xs" color="alternative" onclick={() => { showMentionModal = false; }}>Cancel</Button>
</div>
</div> </div>
</div> </Modal>
{/if}
<!-- Wikilink Modal --> <!-- Wikilink Modal -->
{#if showWikilinkModal} <Modal
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40"> class="modal-leather"
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md"> title="Insert Wikilink"
<h3 class="text-lg font-semibold mb-2">Insert Wikilink</h3> bind:open={showWikilinkModal}
<input autoclose
outsideclose
size="sm"
>
<Input
type="text" type="text"
class="w-full border rounded p-2 mb-2"
placeholder="Target page (e.g. target page or target-page)" placeholder="Target page (e.g. target page or target-page)"
bind:value={wikilinkTarget} bind:value={wikilinkTarget}
class="mb-2"
/> />
<input <Input
type="text" type="text"
class="w-full border rounded p-2 mb-2"
placeholder="Display text (optional)" placeholder="Display text (optional)"
bind:value={wikilinkLabel} bind:value={wikilinkLabel}
class="mb-4"
/> />
<div class="flex justify-end gap-2 mt-4"> <div class="flex justify-end gap-2">
<Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button> <Button size="xs" color="primary" on:click={insertWikilink}>Insert</Button>
<Button size="xs" color="alternative" on:click={() => { showWikilinkModal = false; }}>Cancel</Button> <Button size="xs" color="alternative" on:click={() => { showWikilinkModal = false; }}>Cancel</Button>
</div> </div>
</div> </Modal>
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="space-y-4">
<div> <div>
<Textarea <Textarea
bind:value={content} bind:value={content}
@ -581,12 +489,12 @@
<Alert color="green" dismissable> <Alert color="green" dismissable>
Comment published successfully to {success.relay}!<br/> Comment published successfully to {success.relay}!<br/>
Event ID: <span class="font-mono">{success.eventId}</span> Event ID: <span class="font-mono">{success.eventId}</span>
<a <button
href="/events?id={nip19.neventEncode({ id: success.eventId })}" onclick={handleViewComment}
class="text-primary-600 dark:text-primary-500 hover:underline ml-2" class="text-primary-600 dark:text-primary-500 hover:underline ml-2"
> >
View your comment View your comment
</a> </button>
</Alert> </Alert>
{/if} {/if}

27
src/lib/components/EventDetails.svelte

@ -87,10 +87,14 @@
gotoValue?: string; gotoValue?: string;
} { } {
if (tag[0] === "a" && tag.length > 1) { if (tag[0] === "a" && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(":"); // Parse the a-tag: kind:pubkey:d
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
try {
const naddr = naddrEncode( const naddr = naddrEncode(
{ {
kind: +kind, kind: parseInt(kind),
pubkey, pubkey,
tags: [["d", d]], tags: [["d", d]],
content: "", content: "",
@ -99,7 +103,14 @@
} as any, } as any,
standardRelays, standardRelays,
); );
console.log("Converted a-tag to naddr:", tag[1], "->", naddr);
return { text: `a:${tag[1]}`, gotoValue: naddr }; return { text: `a:${tag[1]}`, gotoValue: naddr };
} catch (error) {
console.error("Error encoding a-tag to naddr:", error);
return { text: `a:${tag[1]}`, gotoValue: tag[1] };
}
}
return { text: `a:${tag[1]}`, gotoValue: tag[1] };
} }
if (tag[0] === "e" && tag.length > 1) { if (tag[0] === "e" && tag.length > 1) {
const nevent = neventEncode( const nevent = neventEncode(
@ -118,6 +129,14 @@
return { text: "" }; return { text: "" };
} }
function navigateToEvent(gotoValue: string) {
console.log("Navigating to event:", gotoValue);
// Add a small delay to ensure the current search state is cleared
setTimeout(() => {
goto(`/events?id=${encodeURIComponent(gotoValue)}`);
}, 10);
}
$effect(() => { $effect(() => {
if (event && event.kind !== 0 && event.content) { if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then((html) => { parseBasicmarkup(event.content).then((html) => {
@ -280,8 +299,8 @@
{#if tagInfo.text && tagInfo.gotoValue} {#if tagInfo.text && tagInfo.gotoValue}
<button <button
onclick={() => onclick={() =>
goto(`/events?id=${encodeURIComponent(tagInfo.gotoValue!)}`)} navigateToEvent(tagInfo.gotoValue!)}
class="underline text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100" class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100"
> >
{tagInfo.text} {tagInfo.text}
</button> </button>

30
src/lib/components/EventInput.svelte

@ -2,11 +2,14 @@
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag, validate30040EventSet, get30040EventDescription, analyze30040Event, get30040FixGuidance } from '$lib/utils/event_input_utils'; 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 { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { userPubkey } from '$lib/stores/authStore'; import { userPubkey } from '$lib/stores/authStore.Svelte';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from '$lib/utils/nostrUtils';
import { prefixNostrAddresses } from '$lib/utils/nostrUtils'; import { prefixNostrAddresses } from '$lib/utils/nostrUtils';
import { standardRelays } from '$lib/consts'; import { standardRelays } from '$lib/consts';
import { Button } from "flowbite-svelte";
import { nip19 } from "nostr-tools";
import { goto } from "$app/navigation";
let kind = $state<number>(30023); let kind = $state<number>(30023);
let tags = $state<[string, string][]>([]); let tags = $state<[string, string][]>([]);
@ -17,16 +20,12 @@
let success = $state<string | null>(null); let success = $state<string | null>(null);
let publishedRelays = $state<string[]>([]); let publishedRelays = $state<string[]>([]);
let pubkey = $state<string | null>(null);
let title = $state(''); let title = $state('');
let dTag = $state(''); let dTag = $state('');
let titleManuallyEdited = $state(false); let titleManuallyEdited = $state(false);
let dTagManuallyEdited = $state(false); let dTagManuallyEdited = $state(false);
let dTagError = $state(''); let dTagError = $state('');
let lastPublishedEventId = $state<string | null>(null); let lastPublishedEventId = $state<string | null>(null);
$effect(() => {
pubkey = get(userPubkey);
});
/** /**
* Extracts the first Markdown/AsciiDoc header as the title. * Extracts the first Markdown/AsciiDoc header as the title.
@ -81,7 +80,9 @@
} }
function validate(): { valid: boolean; reason?: string } { function validate(): { valid: boolean; reason?: string } {
if (!pubkey) return { valid: false, reason: 'Not logged in.' }; const currentUserPubkey = get(userPubkey as any);
if (!currentUserPubkey) return { valid: false, reason: 'Not logged in.' };
const pubkey = String(currentUserPubkey);
if (!content.trim()) return { valid: false, reason: 'Content required.' }; if (!content.trim()) return { valid: false, reason: 'Content required.' };
if (kind === 30023) { if (kind === 30023) {
const v = validateNotAsciidoc(content); const v = validateNotAsciidoc(content);
@ -117,11 +118,13 @@
try { try {
const ndk = get(ndkInstance); const ndk = get(ndkInstance);
if (!ndk || !pubkey) { const currentUserPubkey = get(userPubkey as any);
if (!ndk || !currentUserPubkey) {
error = 'NDK or pubkey missing.'; error = 'NDK or pubkey missing.';
loading = false; loading = false;
return; return;
} }
const pubkey = String(currentUserPubkey);
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) { if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) {
error = 'Invalid public key: must be a 64-character hex string.'; error = 'Invalid public key: must be a 64-character hex string.';
@ -343,6 +346,12 @@
} }
return analysis; return analysis;
} }
function viewPublishedEvent() {
if (lastPublishedEventId) {
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`);
}
}
</script> </script>
<div class='w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg'> <div class='w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg'>
@ -429,12 +438,9 @@
{#if lastPublishedEventId} {#if lastPublishedEventId}
<div class='mt-2 text-green-700'> <div class='mt-2 text-green-700'>
Event ID: <span class='font-mono'>{lastPublishedEventId}</span> Event ID: <span class='font-mono'>{lastPublishedEventId}</span>
<a <Button onclick={viewPublishedEvent} class='text-primary-600 dark:text-primary-500 hover:underline ml-2'>
href={'/events?id=' + lastPublishedEventId}
class='text-primary-600 dark:text-primary-500 hover:underline ml-2'
>
View your event View your event
</a> </Button>
</div> </div>
{/if} {/if}
{/if} {/if}

916
src/lib/components/EventSearch.svelte

File diff suppressed because it is too large Load Diff

57
src/lib/components/LoginModal.svelte

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte"; import { Button, Modal } from "flowbite-svelte";
import { loginWithExtension, ndkSignedIn } from "$lib/ndk"; import { loginWithExtension, ndkSignedIn } from "$lib/ndk";
const { const {
@ -14,6 +14,11 @@
let signInFailed = $state<boolean>(false); let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>(""); let errorMessage = $state<string>("");
let modalOpen = $state(show);
$effect(() => {
modalOpen = show;
});
$effect(() => { $effect(() => {
if ($ndkSignedIn && show) { if ($ndkSignedIn && show) {
@ -22,6 +27,12 @@
} }
}); });
$effect(() => {
if (!modalOpen) {
onClose();
}
});
async function handleSignInClick() { async function handleSignInClick() {
try { try {
signInFailed = false; signInFailed = false;
@ -40,37 +51,15 @@
} }
</script> </script>
{#if show} <Modal
<div class="modal-leather"
class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50" title="Login Required"
> bind:open={modalOpen}
<div class="relative w-auto my-6 mx-auto max-w-3xl"> autoclose
<div outsideclose
class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white dark:bg-gray-800 outline-none focus:outline-none" size="sm"
>
<!-- Header -->
<div
class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t"
>
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">
Login Required
</h3>
<button
class="ml-auto bg-transparent border-0 text-gray-600 float-right text-3xl leading-none font-semibold outline-none focus:outline-none"
onclick={onClose}
>
<span
class="bg-transparent text-gray-700 dark:text-gray-300 h-6 w-6 text-2xl block outline-none focus:outline-none"
</span
>
</button>
</div>
<!-- Body -->
<div class="relative p-6 flex-auto">
<p
class="text-base leading-relaxed text-gray-700 dark:text-gray-300 mb-6"
> >
<p class="text-base leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
You need to be logged in to submit an issue. Your form data will be You need to be logged in to submit an issue. Your form data will be
preserved. preserved.
</p> </p>
@ -88,8 +77,4 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </Modal>
</div>
</div>
</div>
{/if}

12
src/lib/components/Modal.svelte

@ -1,12 +0,0 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
export let showModal;
export let event: NDKEvent;
// let str: string = JSON.stringify(event);
</script>
{#if showModal}
<div class="backdrop">
<div class="Modal">{event.id}</div>
</div>
{/if}

67
src/lib/components/PublicationFeed.svelte

@ -11,6 +11,10 @@
type NDKEvent, type NDKEvent,
type NDKRelaySet, type NDKRelaySet,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache";
import { feedType } from "$lib/stores";
import { isValidNip05Address } from "$lib/utils/search_utility";
let { let {
relays, relays,
@ -45,6 +49,18 @@
(r: string) => !primaryRelays.includes(r), (r: string) => !primaryRelays.includes(r),
); );
const allRelays = [...primaryRelays, ...fallback]; const allRelays = [...primaryRelays, ...fallback];
// Check cache first
const cachedEvents = indexEventCache.get(allRelays);
if (cachedEvents) {
console.log(`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`);
allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
return;
}
relayStatuses = Object.fromEntries( relayStatuses = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]), allRelays.map((r: string) => [r, "pending"]),
); );
@ -91,6 +107,10 @@
allIndexEvents = Array.from(eventMap.values()); allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending // Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!); allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Cache the fetched events
indexEventCache.set(allRelays, allIndexEvents);
// Initially show first page // Initially show first page
eventsInView = allIndexEvents.slice(0, 30); eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30; endOfFeed = allIndexEvents.length <= 30;
@ -108,8 +128,15 @@
events.length, events.length,
); );
// Check cache first for publication search
const cachedResult = searchCache.get('publication', query);
if (cachedResult) {
console.log(`[PublicationFeed] Using cached results for publication search: ${query}`);
return cachedResult.events;
}
// Check if the query is a NIP-05 address // Check if the query is a NIP-05 address
const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query); const isNip05Query = isValidNip05Address(query);
console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query); console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
const filtered = events.filter((event) => { const filtered = events.filter((event) => {
@ -151,6 +178,19 @@
} }
return matches; return matches;
}); });
// Cache the filtered results
const result = {
events: filtered,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: 'publication',
searchTerm: query
};
searchCache.set('publication', query, result);
console.debug("[PublicationFeed] Events after filtering:", filtered.length); console.debug("[PublicationFeed] Events after filtering:", filtered.length);
return filtered; return filtered;
}; };
@ -197,6 +237,30 @@
return skeletonIds; return skeletonIds;
} }
function getCacheStats(): string {
const indexStats = indexEventCache.getStats();
const searchStats = searchCache.size();
return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`;
}
// Track previous feed type to avoid infinite loops
let previousFeedType = $state($feedType);
// Watch for changes in feed type and relay configuration
$effect(() => {
if (previousFeedType !== $feedType) {
console.log(`[PublicationFeed] Feed type changed from ${previousFeedType} to ${$feedType}`);
previousFeedType = $feedType;
// Clear cache when feed type changes (different relay sets)
indexEventCache.clear();
searchCache.clear();
// Refetch events with new relay configuration
fetchAllIndexEventsFromRelays();
}
});
onMount(async () => { onMount(async () => {
await fetchAllIndexEventsFromRelays(); await fetchAllIndexEventsFromRelays();
}); });
@ -217,6 +281,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if !loadingMore && !endOfFeed} {#if !loadingMore && !endOfFeed}
<div class="flex justify-center mt-4 mb-8"> <div class="flex justify-center mt-4 mb-8">
<Button <Button

2
src/lib/components/PublicationHeader.svelte

@ -51,8 +51,6 @@
authorDisplayName = undefined; authorDisplayName = undefined;
} }
}); });
console.log("PublicationHeader event:", event);
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}

10
src/lib/components/PublicationSection.svelte

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
console.log("PublicationSection loaded");
import type { PublicationTree } from "$lib/data_structures/publication_tree"; import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { import {
contentParagraph, contentParagraph,
@ -126,11 +125,6 @@
ref(sectionRef); ref(sectionRef);
}); });
$effect(() => {
if (leafContent) {
console.log("leafContent HTML:", leafContent.toString());
}
});
</script> </script>
<section <section
@ -142,10 +136,6 @@
<TextPlaceholder size="xxl" /> <TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{@const contentString = leafContent.toString()} {@const contentString = leafContent.toString()}
{@const _ = (() => {
console.log("leafContent HTML:", contentString);
return null;
})()}
{#each divergingBranches as [branch, depth]} {#each divergingBranches as [branch, depth]}
{@render sectionHeading( {@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "", getMatchingTags(branch, "title")[0]?.[1] ?? "",

27
src/lib/components/RelayActions.svelte

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Button } from "flowbite-svelte"; import { Button, Modal } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -99,18 +99,14 @@
</div> </div>
</div> </div>
{#if showRelayModal} <Modal
<div class="modal-leather"
class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center" title="Relay Search Results"
bind:open={showRelayModal}
autoclose
outsideclose
size="lg"
> >
<div
class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative"
>
<button
class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
onclick={closeRelayModal}>&times;</button
>
<h2 class="text-lg font-semibold mb-4">Relay Search Results</h2>
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto"> <div class="flex flex-col gap-4 max-h-96 overflow-y-auto">
{#each Object.entries( { "Standard Relays": standardRelays, "User Relays": Array.from($ndkInstance?.pool?.relays.values() || []).map((r) => r.url), "Fallback Relays": fallbackRelays }, ) as [groupName, groupRelays]} {#each Object.entries( { "Standard Relays": standardRelays, "User Relays": Array.from($ndkInstance?.pool?.relays.values() || []).map((r) => r.url), "Fallback Relays": fallbackRelays }, ) as [groupName, groupRelays]}
{#if groupRelays.length > 0} {#if groupRelays.length > 0}
@ -131,9 +127,4 @@
{/if} {/if}
{/each} {/each}
</div> </div>
<div class="mt-4 flex justify-end"> </Modal>
<Button onclick={closeRelayModal}>Close</Button>
</div>
</div>
</div>
{/if}

45
src/lib/components/cards/ProfileHeader.svelte

@ -5,9 +5,11 @@
import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts"; import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts";
import QrCode from "$components/util/QrCode.svelte"; import QrCode from "$components/util/QrCode.svelte";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { lnurlpWellKnownUrl, checkCommunity } from "$lib/utils/search_utility";
// @ts-ignore // @ts-ignore
import { bech32 } from "https://esm.sh/bech32"; import { bech32 } from "https://esm.sh/bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { goto } from "$app/navigation";
const { const {
event, event,
@ -21,13 +23,14 @@
let lnModalOpen = $state(false); let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null); let lnurl = $state<string | null>(null);
let communityStatus = $state<boolean | null>(null);
onMount(async () => { onMount(async () => {
if (profile?.lud16) { if (profile?.lud16) {
try { try {
// Convert LN address to LNURL // Convert LN address to LNURL
const [name, domain] = profile?.lud16.split("@"); const [name, domain] = profile?.lud16.split("@");
const url = `https://${domain}/.well-known/lnurlp/${name}`; const url = lnurlpWellKnownUrl(domain, name);
const words = bech32.toWords(new TextEncoder().encode(url)); const words = bech32.toWords(new TextEncoder().encode(url));
lnurl = bech32.encode("lnurl", words); lnurl = bech32.encode("lnurl", words);
} catch { } catch {
@ -35,6 +38,20 @@
} }
} }
}); });
$effect(() => {
if (event?.pubkey) {
checkCommunity(event.pubkey).then((status) => {
communityStatus = status;
}).catch(() => {
communityStatus = false;
});
}
});
function navigateToIdentifier(link: string) {
goto(link);
}
</script> </script>
{#if profile} {#if profile}
@ -63,6 +80,7 @@
}} }}
/> />
{/if} {/if}
<div class="flex items-center gap-2">
{@render userBadge( {@render userBadge(
toNpub(event.pubkey) as string, toNpub(event.pubkey) as string,
profile.displayName || profile.displayName ||
@ -70,6 +88,16 @@
profile.name || profile.name ||
event.pubkey, event.pubkey,
)} )}
{#if communityStatus === true}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
{:else if communityStatus === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
</div>
</div> </div>
<div> <div>
<div class="mt-2 flex flex-col gap-4"> <div class="mt-2 flex flex-col gap-4">
@ -127,11 +155,16 @@
<div class="flex gap-2"> <div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt> <dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all"> <dd class="break-all">
{#if id.link}<a {#if id.link}
href={id.link} <Button
class="underline text-primary-700 dark:text-primary-200 break-all" class="text-primary-700 dark:text-primary-200"
>{id.value}</a onclick={() => navigateToIdentifier(id.link)}
>{:else}{id.value}{/if} >
{id.value}
</Button>
{:else}
{id.value}
{/if}
</dd> </dd>
</div> </div>
{/each} {/each}

114
src/lib/components/util/CardActions.svelte

@ -1,56 +1,47 @@
<script lang="ts"> <script lang="ts">
import { Button, Modal, Popover } from "flowbite-svelte";
import { import {
ClipboardCleanOutline,
DotsVerticalOutline, DotsVerticalOutline,
EyeOutline, EyeOutline,
ShareNodesOutline, ClipboardCleanOutline,
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte"; import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { standardRelays, FeedType } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { ndkSignedIn, inboxRelays } from "$lib/ndk";
import { feedType } from "$lib/stores"; import { feedType } from "$lib/stores";
import { inboxRelays, ndkSignedIn } from "$lib/ndk"; import { FeedType } from "$lib/consts";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import { goto } from "$app/navigation";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; import type { NDKEvent } from "$lib/utils/nostrUtils";
// Component props
let { event } = $props<{ event: NDKEvent }>();
// Derive metadata from event const {
let title = $derived( event,
event.tags.find((t: string[]) => t[0] === "title")?.[1] ?? "", title,
); author,
let summary = $derived( originalAuthor,
event.tags.find((t: string[]) => t[0] === "summary")?.[1] ?? "", summary,
); image,
let image = $derived( version,
event.tags.find((t: string[]) => t[0] === "image")?.[1] ?? null, source,
); type,
let author = $derived( language,
event.tags.find((t: string[]) => t[0] === "author")?.[1] ?? "", publisher,
); identifier,
let originalAuthor = $derived( } = $props<{
event.tags.find((t: string[]) => t[0] === "original_author")?.[1] ?? null, event: NDKEvent;
); title?: string;
let version = $derived( author?: string;
event.tags.find((t: string[]) => t[0] === "version")?.[1] ?? "", originalAuthor?: string;
); summary?: string;
let source = $derived( image?: string;
event.tags.find((t: string[]) => t[0] === "source")?.[1] ?? null, version?: string;
); source?: string;
let type = $derived( type?: string;
event.tags.find((t: string[]) => t[0] === "type")?.[1] ?? null, language?: string;
); publisher?: string;
let language = $derived( identifier?: string;
event.tags.find((t: string[]) => t[0] === "language")?.[1] ?? null, }>();
);
let publisher = $derived(
event.tags.find((t: string[]) => t[0] === "publisher")?.[1] ?? null,
);
let identifier = $derived(
event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null,
);
// UI state // UI state
let detailsModalOpen: boolean = $state(false); let detailsModalOpen: boolean = $state(false);
@ -83,7 +74,6 @@
* Opens the actions popover menu * Opens the actions popover menu
*/ */
function openPopover() { function openPopover() {
console.debug("[CardActions] Opening menu", { eventId: event.id });
isOpen = true; isOpen = true;
} }
@ -91,7 +81,6 @@
* Closes the actions popover menu and removes focus * Closes the actions popover menu and removes focus
*/ */
function closePopover() { function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false; isOpen = false;
const menu = document.getElementById("dots-" + event.id); const menu = document.getElementById("dots-" + event.id);
if (menu) menu.blur(); if (menu) menu.blur();
@ -105,10 +94,6 @@
function getIdentifier(type: "nevent" | "naddr"): string { function getIdentifier(type: "nevent" | "naddr"): string {
const encodeFn = type === "nevent" ? neventEncode : naddrEncode; const encodeFn = type === "nevent" ? neventEncode : naddrEncode;
const identifier = encodeFn(event, activeRelays); const identifier = encodeFn(event, activeRelays);
console.debug(
"[CardActions] ${type} identifier for event ${event.id}:",
identifier,
);
return identifier; return identifier;
} }
@ -116,22 +101,17 @@
* Opens the event details modal * Opens the event details modal
*/ */
function viewDetails() { function viewDetails() {
console.debug("[CardActions] Opening details modal", {
eventId: event.id,
title: event.title,
author: event.author,
});
detailsModalOpen = true; detailsModalOpen = true;
} }
// Log component initialization /**
console.debug("[CardActions] Initialized", { * Navigates to the event details page
eventId: event.id, */
kind: event.kind, function viewEventDetails() {
pubkey: event.pubkey, const nevent = getIdentifier('nevent');
title: event.title, goto(`/events?id=${encodeURIComponent(nevent)}`);
author: event.author, }
});
</script> </script>
<div <div
@ -265,12 +245,12 @@
{#if identifier} {#if identifier}
<h5 class="text-sm">Identifier: {identifier}</h5> <h5 class="text-sm">Identifier: {identifier}</h5>
{/if} {/if}
<a <button
href="/events?id={getIdentifier('nevent')}"
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold" class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
onclick={viewEventDetails}
> >
View Event Details View Event Details
</a> </button>
</div> </div>
</Modal> </Modal>
</div> </div>

17
src/lib/components/util/Profile.svelte

@ -8,8 +8,7 @@
} from "flowbite-svelte-icons"; } from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte"; import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { goto } from "$app/navigation";
const externalProfileDestination = "./events?id=";
let { pubkey, isNav = false } = $props(); let { pubkey, isNav = false } = $props();
@ -34,6 +33,12 @@
profile = null; profile = null;
} }
function handleViewProfile() {
if (npub) {
goto(`/events?id=${encodeURIComponent(npub)}`);
}
}
function shortenNpub(long: string | undefined) { function shortenNpub(long: string | undefined) {
if (!long) return ""; if (!long) return "";
return long.slice(0, 8) + "…" + long.slice(-4); return long.slice(0, 8) + "…" + long.slice(-4);
@ -71,14 +76,14 @@
/> />
</li> </li>
<li> <li>
<a <button
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0" class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left"
href="{externalProfileDestination}{npub}" onclick={handleViewProfile}
> >
<UserOutline <UserOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none" class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View profile</span> /><span class="underline">View profile</span>
</a> </button>
</li> </li>
{#if isNav} {#if isNav}
<li> <li>

80
src/lib/components/util/ViewPublicationLink.svelte

@ -0,0 +1,80 @@
<script lang="ts">
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils";
import { getEventType } from "$lib/utils/mime";
import { standardRelays } from "$lib/consts";
import { goto } from "$app/navigation";
let { event, className = "" } = $props<{
event: NDKEvent;
className?: string;
}>();
function getDeferralNaddr(event: NDKEvent): string | undefined {
// Look for a 'deferral' tag, e.g. ['deferral', 'naddr1...']
return getMatchingTags(event, "deferral")[0]?.[1];
}
function isAddressableEvent(event: NDKEvent): boolean {
return getEventType(event.kind || 0) === "addressable";
}
function getNaddrAddress(event: NDKEvent): string | null {
if (!isAddressableEvent(event)) {
return null;
}
try {
return naddrEncode(event, standardRelays);
} catch {
return null;
}
}
function getViewPublicationNaddr(event: NDKEvent): string | null {
// First, check for a-tags with 'defer' - these indicate the event is deferring to someone else's version
const aTags = getMatchingTags(event, "a");
for (const tag of aTags) {
if (tag.length >= 2 && tag.includes("defer")) {
// This is a deferral to someone else's addressable event
return tag[1]; // Return the addressable event address
}
}
// For deferred events with deferral tag, use the deferral naddr instead of the event's own naddr
const deferralNaddr = getDeferralNaddr(event);
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function navigateToPublication() {
const naddrAddress = getViewPublicationNaddr(event);
console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind,
naddrAddress,
isAddressable: isAddressableEvent(event)
});
if (naddrAddress) {
console.log("ViewPublicationLink: Navigating to publication:", naddrAddress);
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`);
} else {
console.log("ViewPublicationLink: No naddr address found for event");
}
}
let naddrAddress = $derived(getViewPublicationNaddr(event));
</script>
{#if naddrAddress}
<button
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg transition-colors {className}"
onclick={navigateToPublication}
tabindex="0"
>
View Publication
</button>
{/if}

3
src/lib/consts.ts

@ -1,7 +1,8 @@
export const wikiKind = 30818; export const wikiKind = 30818;
export const indexKind = 30040; export const indexKind = 30040;
export const zettelKinds = [30041, 30818]; export const zettelKinds = [30041, 30818];
export const communityRelay = ["wss://theforest.nostr1.com"]; export const communityRelay = "wss://theforest.nostr1.com";
export const profileRelay = "wss://profiles.nostr1.com";
export const standardRelays = [ export const standardRelays = [
"wss://thecitadel.nostr1.com", "wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com", "wss://theforest.nostr1.com",

2
src/lib/ndk.ts

@ -15,7 +15,7 @@ import {
anonymousRelays, anonymousRelays,
} from "./consts"; } from "./consts";
import { feedType } from "./stores"; import { feedType } from "./stores";
import { userPubkey } from '$lib/stores/authStore'; import { userPubkey } from '$lib/stores/authStore.Svelte';
export const ndkInstance: Writable<NDK> = writable(); export const ndkInstance: Writable<NDK> = writable();

27
src/lib/snippets/UserSnippets.svelte

@ -1,22 +1,31 @@
<script module lang="ts"> <script module lang="ts">
import { import {
createProfileLink,
createProfileLinkWithVerification,
toNpub, toNpub,
} from "$lib/utils/nostrUtils"; } from "$lib/utils/nostrUtils";
import { goto } from "$app/navigation";
export { userBadge }; export { userBadge };
</script> </script>
{#snippet userBadge(identifier: string, displayText: string | undefined)} {#snippet userBadge(identifier: string, displayText: string | undefined)}
{#if toNpub(identifier)} {#if toNpub(identifier)}
{#await createProfileLinkWithVerification(toNpub(identifier) as string, displayText)} {@const npub = toNpub(identifier) as string}
{@html createProfileLink(toNpub(identifier) as string, displayText)} {@const cleanId = npub.replace(/^nostr:/, "")}
{:then html} {@const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`}
{@html html} {@const displayTextFinal = displayText || defaultText}
{:catch}
{@html createProfileLink(toNpub(identifier) as string, displayText)} <button
{/await} class="npub-badge hover:underline"
onclick={() => goto(`/events?id=${encodeURIComponent(cleanId)}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/events?id=${encodeURIComponent(cleanId)}`);
}
}}
>
@{displayTextFinal}
</button>
{:else} {:else}
{displayText ?? ""} {displayText ?? ""}
{/if} {/if}

0
src/lib/stores/authStore.ts → src/lib/stores/authStore.Svelte.ts

65
src/lib/utils/community_checker.ts

@ -0,0 +1,65 @@
import { communityRelay } from '$lib/consts';
import { RELAY_CONSTANTS, SEARCH_LIMITS } from './search_constants';
// Cache for pubkeys with kind 1 events on communityRelay
const communityCache = new Map<string, boolean>();
/**
* Check if a pubkey has posted to the community relay
*/
export async function checkCommunity(pubkey: string): Promise<boolean> {
if (communityCache.has(pubkey)) {
return communityCache.get(pubkey)!;
}
try {
const relayUrl = communityRelay;
const ws = new WebSocket(relayUrl);
return await new Promise((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', RELAY_CONSTANTS.COMMUNITY_REQUEST_ID, {
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
authors: [pubkey],
limit: SEARCH_LIMITS.COMMUNITY_CHECK
}
]));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[2]?.kind === 1) {
communityCache.set(pubkey, true);
ws.close();
resolve(true);
} else if (data[0] === 'EOSE') {
communityCache.set(pubkey, false);
ws.close();
resolve(false);
}
};
ws.onerror = () => {
communityCache.set(pubkey, false);
ws.close();
resolve(false);
};
});
} catch {
communityCache.set(pubkey, false);
return false;
}
}
/**
* Check community status for multiple profiles
*/
export async function checkCommunityStatus(profiles: Array<{ pubkey?: string }>): Promise<Record<string, boolean>> {
const communityStatus: Record<string, boolean> = {};
for (const profile of profiles) {
if (profile.pubkey) {
communityStatus[profile.pubkey] = await checkCommunity(profile.pubkey);
}
}
return communityStatus;
}

3
src/lib/utils/event_input_utils.ts

@ -2,6 +2,7 @@ import type { NDKEvent } from './nostrUtils';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
import { EVENT_KINDS } from './search_constants';
// ========================= // =========================
// Validation // Validation
@ -11,7 +12,7 @@ import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
* Returns true if the event kind requires a d-tag (kinds 30000-39999). * Returns true if the event kind requires a d-tag (kinds 30000-39999).
*/ */
export function requiresDTag(kind: number): boolean { export function requiresDTag(kind: number): boolean {
return kind >= 30000 && kind <= 39999; return kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind <= EVENT_KINDS.ADDRESSABLE.MAX;
} }
/** /**

143
src/lib/utils/event_search.ts

@ -0,0 +1,143 @@
import { ndkInstance } from '$lib/ndk';
import { fetchEventWithFallback } from '$lib/utils/nostrUtils';
import { nip19 } from '$lib/utils/nostrUtils';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { get } from 'svelte/store';
import { wellKnownUrl, isValidNip05Address } from './search_utils';
import { TIMEOUTS, VALIDATION } from './search_constants';
/**
* Search for a single event by ID or filter
*/
export async function searchEvent(query: string): Promise<NDKEvent | null> {
// Clean the query and normalize to lowercase
let cleanedQuery = query.replace(/^nostr:/, "").toLowerCase();
let filterOrId: any = cleanedQuery;
// If it's a valid hex string, try as event id first, then as pubkey (profile)
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(cleanedQuery)) {
// Try as event id
filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback(
get(ndkInstance),
filterOrId,
TIMEOUTS.EVENT_FETCH,
);
// Always try as pubkey (profile event) as well
const profileFilter = { kinds: [0], authors: [cleanedQuery] };
const profileEvent = await fetchEventWithFallback(
get(ndkInstance),
profileFilter,
TIMEOUTS.EVENT_FETCH,
);
// Prefer profile if found and pubkey matches query
if (
profileEvent &&
profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()
) {
return profileEvent;
} else if (eventResult) {
return eventResult;
}
} else if (
new RegExp(`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`, 'i').test(cleanedQuery)
) {
try {
const decoded = nip19.decode(cleanedQuery);
if (!decoded) throw new Error("Invalid identifier");
switch (decoded.type) {
case "nevent":
filterOrId = decoded.data.id;
break;
case "note":
filterOrId = decoded.data;
break;
case "naddr":
filterOrId = {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
"#d": [decoded.data.identifier],
};
break;
case "nprofile":
filterOrId = {
kinds: [0],
authors: [decoded.data.pubkey],
};
break;
case "npub":
filterOrId = {
kinds: [0],
authors: [decoded.data],
};
break;
default:
filterOrId = cleanedQuery;
}
} catch (e) {
console.error("[Search] Invalid Nostr identifier:", cleanedQuery, e);
throw new Error("Invalid Nostr identifier.");
}
}
try {
const event = await fetchEventWithFallback(
get(ndkInstance),
filterOrId,
TIMEOUTS.EVENT_FETCH,
);
if (!event) {
console.warn("[Search] Event not found for filterOrId:", filterOrId);
return null;
} else {
return event;
}
} catch (err) {
console.error("[Search] Error fetching event:", err, "Query:", query);
throw new Error("Error fetching event. Please check the ID and try again.");
}
}
/**
* Search for NIP-05 address
*/
export async function searchNip05(nip05Address: string): Promise<NDKEvent | null> {
// NIP-05 address pattern: user@domain
if (!isValidNip05Address(nip05Address)) {
throw new Error("Invalid NIP-05 address format. Expected: user@domain");
}
try {
const [name, domain] = nip05Address.split("@");
const res = await fetch(wellKnownUrl(domain, name));
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
const profileFilter = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback(
get(ndkInstance),
profileFilter,
TIMEOUTS.EVENT_FETCH,
);
if (profileEvent) {
return profileEvent;
} else {
throw new Error(`No profile found for ${name}@${domain} (pubkey: ${pubkey})`);
}
} else {
throw new Error(`NIP-05 address not found: ${name}@${domain}`);
}
} catch (e) {
console.error(`[Search] Error resolving NIP-05 address ${nip05Address}:`, e);
const errorMessage = e instanceof Error ? e.message : String(e);
throw new Error(`Error resolving NIP-05 address: ${errorMessage}`);
}
}

132
src/lib/utils/indexEventCache.ts

@ -0,0 +1,132 @@
import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants';
export interface IndexEventCacheEntry {
events: NDKEvent[];
timestamp: number;
relayUrls: string[];
}
class IndexEventCache {
private cache: Map<string, IndexEventCacheEntry> = new Map();
private readonly CACHE_DURATION = CACHE_DURATIONS.INDEX_EVENT_CACHE;
private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached relay combinations
/**
* Generate a cache key based on relay URLs
*/
private generateKey(relayUrls: string[]): string {
return relayUrls.sort().join('|');
}
/**
* Check if a cached entry is still valid
*/
private isExpired(entry: IndexEventCacheEntry): boolean {
return Date.now() - entry.timestamp > this.CACHE_DURATION;
}
/**
* Get cached index events for a set of relays
*/
get(relayUrls: string[]): NDKEvent[] | null {
const key = this.generateKey(relayUrls);
const entry = this.cache.get(key);
if (!entry || this.isExpired(entry)) {
if (entry) {
this.cache.delete(key);
}
return null;
}
console.log(`[IndexEventCache] Using cached index events for ${relayUrls.length} relays`);
return entry.events;
}
/**
* Store index events in cache
*/
set(relayUrls: string[], events: NDKEvent[]): void {
const key = this.generateKey(relayUrls);
// Implement LRU eviction if cache is full
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
events,
timestamp: Date.now(),
relayUrls: [...relayUrls]
});
console.log(`[IndexEventCache] Cached ${events.length} index events for ${relayUrls.length} relays`);
}
/**
* Check if index events are cached for a set of relays
*/
has(relayUrls: string[]): boolean {
const key = this.generateKey(relayUrls);
const entry = this.cache.get(key);
return entry !== undefined && !this.isExpired(entry);
}
/**
* Clear expired entries from cache
*/
cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (this.isExpired(entry)) {
this.cache.delete(key);
}
}
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
}
/**
* Get cache size
*/
size(): number {
return this.cache.size;
}
/**
* Get cache statistics
*/
getStats(): { size: number; totalEvents: number; oldestEntry: number | null } {
let totalEvents = 0;
let oldestTimestamp: number | null = null;
for (const entry of this.cache.values()) {
totalEvents += entry.events.length;
if (oldestTimestamp === null || entry.timestamp < oldestTimestamp) {
oldestTimestamp = entry.timestamp;
}
}
return {
size: this.cache.size,
totalEvents,
oldestEntry: oldestTimestamp
};
}
}
export const indexEventCache = new IndexEventCache();
// Clean up expired entries periodically
setInterval(() => {
indexEventCache.cleanup();
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute

1
src/lib/utils/markup/asciidoctorPostProcessor.ts

@ -120,7 +120,6 @@ export async function postProcessAsciidoctorHtml(
if (!html) return html; if (!html) return html;
try { try {
console.log('HTML before replaceWikilinks:', html);
// First process AsciiDoctor-generated anchors // First process AsciiDoctor-generated anchors
let processedHtml = replaceAsciiDocAnchors(html); let processedHtml = replaceAsciiDocAnchors(html);
// Then process wikilinks in [[...]] format (if any remain) // Then process wikilinks in [[...]] format (if any remain)

9
src/lib/utils/mime.ts

@ -1,3 +1,5 @@
import { EVENT_KINDS } from './search_constants';
/** /**
* Determine the type of Nostr event based on its kind number * Determine the type of Nostr event based on its kind number
* Following NIP specification for kind ranges: * Following NIP specification for kind ranges:
@ -10,15 +12,16 @@ export function getEventType(
kind: number, kind: number,
): "regular" | "replaceable" | "ephemeral" | "addressable" { ): "regular" | "replaceable" | "ephemeral" | "addressable" {
// Check special ranges first // Check special ranges first
if (kind >= 30000 && kind < 40000) { if (kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind < EVENT_KINDS.ADDRESSABLE.MAX) {
return "addressable"; return "addressable";
} }
if (kind >= 20000 && kind < 30000) { if (kind >= EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MIN && kind < EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MAX) {
return "ephemeral"; return "ephemeral";
} }
if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) { if ((kind >= EVENT_KINDS.REPLACEABLE.MIN && kind < EVENT_KINDS.REPLACEABLE.MAX) ||
EVENT_KINDS.REPLACEABLE.SPECIFIC.includes(kind as 0 | 3)) {
return "replaceable"; return "replaceable";
} }

11
src/lib/utils/nostrEventService.ts

@ -5,6 +5,7 @@ import { userRelays } from "$lib/stores/relayStore";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { NDKEvent } from "./nostrUtils"; import type { NDKEvent } from "./nostrUtils";
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants';
export interface RootEventInfo { export interface RootEventInfo {
rootId: string; rootId: string;
@ -178,8 +179,8 @@ export function buildReplyTags(
): string[][] { ): string[][] {
const tags: string[][] = []; const tags: string[][] = [];
const isParentReplaceable = parentInfo.parentKind >= 30000 && parentInfo.parentKind < 40000; const isParentReplaceable = parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN && parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
const isParentComment = parentInfo.parentKind === 1111; const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT;
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id; const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id;
if (kind === 1) { if (kind === 1) {
@ -199,7 +200,7 @@ export function buildReplyTags(
} }
} }
} else { } else {
// Kind 1111 uses NIP-22 threading format // Kind 1111 (comment) uses NIP-22 threading format
if (isParentReplaceable) { if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd'); const dTag = getTagValue(parent.tags || [], 'd');
if (dTag) { if (dTag) {
@ -292,7 +293,7 @@ export async function createSignedEvent(
const eventToSign = { const eventToSign = {
kind: Number(kind), kind: Number(kind),
created_at: Number(Math.floor(Date.now() / 1000)), created_at: Number(Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR)),
tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]), tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]),
content: String(prefixedContent), content: String(prefixedContent),
pubkey: pubkey, pubkey: pubkey,
@ -329,7 +330,7 @@ async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void>
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
ws.close(); ws.close();
reject(new Error("Timeout")); reject(new Error("Timeout"));
}, 5000); }, TIMEOUTS.GENERAL);
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", signedEvent])); ws.send(JSON.stringify(["EVENT", signedEvent]));

40
src/lib/utils/nostrUtils.ts

@ -9,6 +9,8 @@ import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from "@noble/hashes/sha256"; import { sha256 } from "@noble/hashes/sha256";
import { schnorr } from "@noble/curves/secp256k1"; import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";
import { wellKnownUrl } from "./search_utility";
import { TIMEOUTS, VALIDATION } from './search_constants';
const badgeCheckSvg = const badgeCheckSvg =
'<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>'; '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>';
@ -264,19 +266,35 @@ export async function processNostrIdentifiers(
export async function getNpubFromNip05(nip05: string): Promise<string | null> { export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try { try {
const ndk = get(ndkInstance); // Parse the NIP-05 address
if (!ndk) { const [name, domain] = nip05.split('@');
console.error("NDK not initialized"); if (!name || !domain) {
console.error('[getNpubFromNip05] Invalid NIP-05 format:', nip05);
return null;
}
// Fetch the well-known.json file
const url = wellKnownUrl(domain, name);
const response = await fetch(url);
if (!response.ok) {
console.error('[getNpubFromNip05] HTTP error:', response.status, response.statusText);
return null; return null;
} }
const user = await ndk.getUser({ nip05 }); const data = await response.json();
if (!user || !user.npub) {
const pubkey = data.names?.[name];
if (!pubkey) {
console.error('[getNpubFromNip05] No pubkey found for name:', name);
return null; return null;
} }
return user.npub;
// Convert pubkey to npub
const npub = nip19.npubEncode(pubkey);
return npub;
} catch (error) { } catch (error) {
console.error("Error getting npub from nip05:", error); console.error("[getNpubFromNip05] Error getting npub from nip05:", error);
return null; return null;
} }
} }
@ -284,8 +302,8 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
/** /**
* Generic utility function to add a timeout to any promise * Generic utility function to add a timeout to any promise
* Can be used in two ways: * Can be used in two ways:
* 1. Method style: promise.withTimeout(5000) * 1. Method style: promise.withTimeout(TIMEOUTS.GENERAL)
* 2. Function style: withTimeout(promise, 5000) * 2. Function style: withTimeout(promise, TIMEOUTS.GENERAL)
* *
* @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style) * @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style)
* @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style) * @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style)
@ -376,7 +394,7 @@ export async function fetchEventWithFallback(
if ( if (
typeof filterOrId === "string" && typeof filterOrId === "string" &&
/^[0-9a-f]{64}$/i.test(filterOrId) new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(filterOrId)
) { ) {
return await ndk return await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet) .fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
@ -446,7 +464,7 @@ export async function fetchEventWithFallback(
export function toNpub(pubkey: string | undefined): string | null { export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null; if (!pubkey) return null;
try { try {
if (/^[a-f0-9]{64}$/i.test(pubkey)) { if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(pubkey)) {
return nip19.npubEncode(pubkey); return nip19.npubEncode(pubkey);
} }
if (pubkey.startsWith("npub1")) return pubkey; if (pubkey.startsWith("npub1")) return pubkey;

233
src/lib/utils/profile_search.ts

@ -0,0 +1,233 @@
import { ndkInstance } from '$lib/ndk';
import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { searchCache } from '$lib/utils/searchCache';
import { communityRelay, profileRelay } from '$lib/consts';
import { get } from 'svelte/store';
import type { NostrProfile, ProfileSearchResult } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils';
import { checkCommunityStatus } from './community_checker';
import { TIMEOUTS } from './search_constants';
/**
* Search for profiles by various criteria (display name, name, NIP-05, npub)
*/
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
// Check cache first
const cachedResult = searchCache.get('profile', normalizedSearchTerm);
if (cachedResult) {
const profiles = cachedResult.events.map(event => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
}).filter(Boolean) as NostrProfile[];
const communityStatus = await checkCommunityStatus(profiles);
return { profiles, Status: communityStatus };
}
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error('NDK not initialized');
}
let foundProfiles: NostrProfile[] = [];
let timeoutId: ReturnType<typeof setTimeout> | null = null;
// Set a timeout to force completion after profile search timeout
timeoutId = setTimeout(() => {
if (foundProfiles.length === 0) {
// Timeout reached, but no need to log this
}
}, TIMEOUTS.PROFILE_SEARCH);
try {
// Check if it's a valid npub/nprofile first
if (normalizedSearchTerm.startsWith('npub') || normalizedSearchTerm.startsWith('nprofile')) {
try {
const metadata = await getUserMetadata(normalizedSearchTerm);
if (metadata) {
foundProfiles = [metadata];
}
} catch (error) {
console.error('Error fetching metadata for npub:', error);
}
} else if (normalizedSearchTerm.includes('@')) {
// Check if it's a NIP-05 address
try {
const npub = await getNpubFromNip05(normalizedSearchTerm);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
};
foundProfiles = [profile];
}
} catch (e) {
console.error('[Search] NIP-05 lookup failed:', e);
// If NIP-05 lookup fails, continue with regular search
}
} else {
// Try searching for NIP-05 addresses that match the search term
foundProfiles = await searchNip05Domains(normalizedSearchTerm, ndk);
// If no NIP-05 results found, search for profiles across relays
if (foundProfiles.length === 0) {
foundProfiles = await searchProfilesAcrossRelays(normalizedSearchTerm, ndk);
}
}
// Wait for search to complete or timeout
await new Promise<void>((resolve) => {
const checkComplete = () => {
if (timeoutId === null || foundProfiles.length > 0) {
resolve();
} else {
setTimeout(checkComplete, 100);
}
};
checkComplete();
});
// Cache the results
if (foundProfiles.length > 0) {
const events = foundProfiles.map(profile => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || '';
return event;
});
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: 'profile',
searchTerm: normalizedSearchTerm
};
searchCache.set('profile', normalizedSearchTerm, result);
}
// Check community status for all profiles
const communityStatus = await checkCommunityStatus(foundProfiles);
return { profiles: foundProfiles, Status: communityStatus };
} catch (error) {
console.error('Error searching profiles:', error);
return { profiles: [], Status: {} };
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
/**
* Search for NIP-05 addresses across common domains
*/
async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
try {
for (const domain of COMMON_DOMAINS) {
const nip05Address = `${searchTerm}@${domain}`;
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
};
return [profile];
}
} catch (e) {
// Continue to next domain
}
}
} catch (e) {
console.error('[Search] NIP-05 domain search failed:', e);
}
return [];
}
/**
* Search for profiles across relays
*/
async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = [];
// Prioritize community relays for better search results
const allRelays = Array.from(ndk.pool.relays.values()) as any[];
const prioritizedRelays = new Set([
...allRelays.filter((relay: any) => relay.url === communityRelay),
...allRelays.filter((relay: any) => relay.url !== communityRelay)
]);
const relaySet = new NDKRelaySet(prioritizedRelays as any, ndk);
// Subscribe to profile events
const sub = ndk.subscribe(
{ kinds: [0] },
{ closeOnEose: true },
relaySet
);
return new Promise((resolve) => {
sub.on('event', (event: NDKEvent) => {
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.displayName || profileData.display_name || '';
const display_name = profileData.display_name || '';
const name = profileData.name || '';
const nip05 = profileData.nip05 || '';
const about = profileData.about || '';
// Check if any field matches the search term
const matchesDisplayName = fieldMatches(displayName, searchTerm);
const matchesDisplay_name = fieldMatches(display_name, searchTerm);
const matchesName = fieldMatches(name, searchTerm);
const matchesNip05 = nip05Matches(nip05, searchTerm);
const matchesAbout = fieldMatches(about, searchTerm);
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) {
const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile
const existingIndex = foundProfiles.findIndex(p => p.pubkey === event.pubkey);
if (existingIndex === -1) {
foundProfiles.push(profile);
}
}
} catch (e) {
// Invalid JSON or other error, skip
}
});
sub.on('eose', () => {
if (foundProfiles.length > 0) {
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { profile: NostrProfile; created_at: number }> = {};
for (const profile of foundProfiles) {
const pubkey = profile.pubkey;
if (pubkey) {
// We don't have created_at from getUserMetadata, so just keep the first one
if (!deduped[pubkey]) {
deduped[pubkey] = { profile, created_at: 0 };
}
}
}
const dedupedProfiles = Object.values(deduped).map(x => x.profile);
resolve(dedupedProfiles);
} else {
resolve([]);
}
});
});
}

3
src/lib/utils/relayDiagnostics.ts

@ -1,5 +1,6 @@
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts'; import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts';
import NDK from '@nostr-dev-kit/ndk'; import NDK from '@nostr-dev-kit/ndk';
import { TIMEOUTS } from './search_constants';
export interface RelayDiagnostic { export interface RelayDiagnostic {
url: string; url: string;
@ -31,7 +32,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> {
responseTime: Date.now() - startTime, responseTime: Date.now() - startTime,
}); });
} }
}, 5000); }, TIMEOUTS.RELAY_DIAGNOSTICS);
ws.onopen = () => { ws.onopen = () => {
if (!resolved) { if (!resolved) {

105
src/lib/utils/searchCache.ts

@ -0,0 +1,105 @@
import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants';
export interface SearchResult {
events: NDKEvent[];
secondOrder: NDKEvent[];
tTagEvents: NDKEvent[];
eventIds: Set<string>;
addresses: Set<string>;
searchType: string;
searchTerm: string;
timestamp: number;
}
class SearchCache {
private cache: Map<string, SearchResult> = new Map();
private readonly CACHE_DURATION = CACHE_DURATIONS.SEARCH_CACHE;
/**
* Generate a cache key for a search
*/
private generateKey(searchType: string, searchTerm: string): string {
if (!searchTerm) {
return `${searchType}:`;
}
return `${searchType}:${searchTerm.toLowerCase().trim()}`;
}
/**
* Check if a cached result is still valid
*/
private isExpired(result: SearchResult): boolean {
return Date.now() - result.timestamp > this.CACHE_DURATION;
}
/**
* Get cached search results
*/
get(searchType: string, searchTerm: string): SearchResult | null {
const key = this.generateKey(searchType, searchTerm);
const result = this.cache.get(key);
if (!result || this.isExpired(result)) {
if (result) {
this.cache.delete(key);
}
return null;
}
return result;
}
/**
* Store search results in cache
*/
set(searchType: string, searchTerm: string, result: Omit<SearchResult, 'timestamp'>): void {
const key = this.generateKey(searchType, searchTerm);
this.cache.set(key, {
...result,
timestamp: Date.now()
});
}
/**
* Check if a search result is cached and valid
*/
has(searchType: string, searchTerm: string): boolean {
const key = this.generateKey(searchType, searchTerm);
const result = this.cache.get(key);
return result !== undefined && !this.isExpired(result);
}
/**
* Clear expired entries from cache
*/
cleanup(): void {
const now = Date.now();
for (const [key, result] of this.cache.entries()) {
if (this.isExpired(result)) {
this.cache.delete(key);
}
}
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
}
/**
* Get cache size
*/
size(): number {
return this.cache.size;
}
}
export const searchCache = new SearchCache();
// Clean up expired entries periodically
setInterval(() => {
searchCache.cleanup();
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute

121
src/lib/utils/search_constants.ts

@ -0,0 +1,121 @@
/**
* Search and Event Utility Constants
*
* This file centralizes all magic numbers used throughout the search and event utilities
* to improve maintainability and reduce code duplication.
*/
// Timeout constants (in milliseconds)
export const TIMEOUTS = {
/** Default timeout for event fetching operations */
EVENT_FETCH: 10000,
/** Timeout for profile search operations */
PROFILE_SEARCH: 15000,
/** Timeout for subscription search operations */
SUBSCRIPTION_SEARCH: 30000,
/** Timeout for relay diagnostics */
RELAY_DIAGNOSTICS: 5000,
/** Timeout for general operations */
GENERAL: 5000,
/** Cache cleanup interval */
CACHE_CLEANUP: 60000,
} as const;
// Cache duration constants (in milliseconds)
export const CACHE_DURATIONS = {
/** Default cache duration for search results */
SEARCH_CACHE: 5 * 60 * 1000, // 5 minutes
/** Cache duration for index events */
INDEX_EVENT_CACHE: 10 * 60 * 1000, // 10 minutes
} as const;
// Search limits
export const SEARCH_LIMITS = {
/** Limit for specific profile searches (npub, NIP-05) */
SPECIFIC_PROFILE: 10,
/** Limit for general profile searches */
GENERAL_PROFILE: 500,
/** Limit for community relay checks */
COMMUNITY_CHECK: 1,
/** Limit for second-order search results */
SECOND_ORDER_RESULTS: 100,
} as const;
// Nostr event kind ranges
export const EVENT_KINDS = {
/** Replaceable event kinds (0, 3, 10000-19999) */
REPLACEABLE: {
MIN: 0,
MAX: 19999,
SPECIFIC: [0, 3],
},
/** Parameterized replaceable event kinds (20000-29999) */
PARAMETERIZED_REPLACEABLE: {
MIN: 20000,
MAX: 29999,
},
/** Addressable event kinds (30000-39999) */
ADDRESSABLE: {
MIN: 30000,
MAX: 39999,
},
/** Comment event kind */
COMMENT: 1111,
/** Text note event kind */
TEXT_NOTE: 1,
/** Profile metadata event kind */
PROFILE_METADATA: 0,
} as const;
// Relay-specific constants
export const RELAY_CONSTANTS = {
/** Request ID for community relay checks */
COMMUNITY_REQUEST_ID: 'alexandria-forest',
/** Default relay request kinds for community checks */
COMMUNITY_REQUEST_KINDS: [1],
} as const;
// Time constants
export const TIME_CONSTANTS = {
/** Unix timestamp conversion factor (seconds to milliseconds) */
UNIX_TIMESTAMP_FACTOR: 1000,
/** Current timestamp in seconds */
CURRENT_TIMESTAMP: Math.floor(Date.now() / 1000),
} as const;
// Validation constants
export const VALIDATION = {
/** Hex string length for event IDs and pubkeys */
HEX_LENGTH: 64,
/** Minimum length for Nostr identifiers */
MIN_NOSTR_IDENTIFIER_LENGTH: 4,
} as const;
// HTTP status codes
export const HTTP_STATUS = {
/** OK status code */
OK: 200,
/** Not found status code */
NOT_FOUND: 404,
/** Internal server error status code */
INTERNAL_SERVER_ERROR: 500,
} as const;

69
src/lib/utils/search_types.ts

@ -0,0 +1,69 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
/**
* Extended NostrProfile interface for search results
*/
export interface NostrProfile {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
pubkey?: string;
}
/**
* Search result interface for subscription-based searches
*/
export interface SearchResult {
events: NDKEvent[];
secondOrder: NDKEvent[];
tTagEvents: NDKEvent[];
eventIds: Set<string>;
addresses: Set<string>;
searchType: string;
searchTerm: string;
}
/**
* Profile search result interface
*/
export interface ProfileSearchResult {
profiles: NostrProfile[];
Status: Record<string, boolean>;
}
/**
* Search subscription type
*/
export type SearchSubscriptionType = 'd' | 't' | 'n';
/**
* Search filter configuration
*/
export interface SearchFilter {
filter: any;
subscriptionType: string;
}
/**
* Second-order search parameters
*/
export interface SecondOrderSearchParams {
searchType: 'n' | 'd';
firstOrderEvents: NDKEvent[];
eventIds?: Set<string>;
addresses?: Set<string>;
targetPubkey?: string;
}
/**
* Search callback functions
*/
export interface SearchCallbacks {
onSecondOrderUpdate?: (result: SearchResult) => void;
onSubscriptionCreated?: (sub: any) => void;
}

25
src/lib/utils/search_utility.ts

@ -0,0 +1,25 @@
// Re-export all search functionality from modular files
export * from './search_types';
export * from './search_utils';
export * from './community_checker';
export * from './profile_search';
export * from './event_search';
export * from './subscription_search';
export * from './search_constants';
// Legacy exports for backward compatibility
export { searchProfiles } from './profile_search';
export { searchBySubscription } from './subscription_search';
export { searchEvent, searchNip05 } from './event_search';
export { checkCommunity } from './community_checker';
export {
wellKnownUrl,
lnurlpWellKnownUrl,
isValidNip05Address,
normalizeSearchTerm,
fieldMatches,
nip05Matches,
COMMON_DOMAINS,
isEmojiReaction,
createProfileFromEvent
} from './search_utils';

104
src/lib/utils/search_utils.ts

@ -0,0 +1,104 @@
/**
* Generate well-known NIP-05 URL
*/
export function wellKnownUrl(domain: string, name: string): string {
return `https://${domain}/.well-known/nostr.json?name=${name}`;
}
/**
* Generate well-known LNURLp URL for Lightning Network addresses
*/
export function lnurlpWellKnownUrl(domain: string, name: string): string {
return `https://${domain}/.well-known/lnurlp/${name}`;
}
/**
* Validate NIP-05 address format
*/
export function isValidNip05Address(address: string): boolean {
return /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(address);
}
/**
* Helper function to normalize search terms
*/
export function normalizeSearchTerm(term: string): string {
return term.toLowerCase().replace(/\s+/g, '');
}
/**
* Helper function to check if a profile field matches the search term
*/
export function fieldMatches(field: string, searchTerm: string): boolean {
if (!field) return false;
const fieldLower = field.toLowerCase();
const fieldNormalized = fieldLower.replace(/\s+/g, '');
const searchTermLower = searchTerm.toLowerCase();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
// Check exact match
if (fieldLower === searchTermLower) return true;
if (fieldNormalized === normalizedSearchTerm) return true;
// Check if field contains the search term
if (fieldLower.includes(searchTermLower)) return true;
if (fieldNormalized.includes(normalizedSearchTerm)) return true;
// Check individual words (handle spaces in display names)
const words = fieldLower.split(/\s+/);
return words.some(word => word.includes(searchTermLower));
}
/**
* Helper function to check if NIP-05 address matches the search term
*/
export function nip05Matches(nip05: string, searchTerm: string): boolean {
if (!nip05) return false;
const nip05Lower = nip05.toLowerCase();
const searchTermLower = searchTerm.toLowerCase();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
// Check if the part before @ contains the search term
const atIndex = nip05Lower.indexOf('@');
if (atIndex !== -1) {
const localPart = nip05Lower.substring(0, atIndex);
const localPartNormalized = localPart.replace(/\s+/g, '');
return localPart.includes(searchTermLower) || localPartNormalized.includes(normalizedSearchTerm);
}
return false;
}
/**
* Common domains for NIP-05 lookups
*/
export const COMMON_DOMAINS = [
'gitcitadel.com',
'theforest.nostr1.com',
'nostr1.com',
'nostr.land',
'sovbit.host'
] as const;
/**
* Check if an event is an emoji reaction (kind 7)
*/
export function isEmojiReaction(event: any): boolean {
return event.kind === 7;
}
/**
* Create a profile object from event data
*/
export function createProfileFromEvent(event: any, profileData: any): any {
return {
name: profileData.name,
displayName: profileData.displayName || profileData.display_name,
nip05: profileData.nip05,
picture: profileData.picture,
about: profileData.about,
banner: profileData.banner,
website: profileData.website,
lud16: profileData.lud16,
pubkey: event.pubkey
};
}

651
src/lib/utils/subscription_search.ts

@ -0,0 +1,651 @@
import { ndkInstance } from '$lib/ndk';
import { getMatchingTags, getNpubFromNip05 } from '$lib/utils/nostrUtils';
import { nip19 } from '$lib/utils/nostrUtils';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { searchCache } from '$lib/utils/searchCache';
import { communityRelay, profileRelay } from '$lib/consts';
import { get } from 'svelte/store';
import type { SearchResult, SearchSubscriptionType, SearchFilter, SearchCallbacks, SecondOrderSearchParams } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, isEmojiReaction } from './search_utils';
import { TIMEOUTS, SEARCH_LIMITS } from './search_constants';
/**
* Search for events by subscription type (d, t, n)
*/
export async function searchBySubscription(
searchType: SearchSubscriptionType,
searchTerm: string,
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal
): Promise<SearchResult> {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log("subscription_search: Starting search:", { searchType, searchTerm, normalizedSearchTerm });
// Check cache first
const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
if (cachedResult) {
console.log("subscription_search: Found cached result:", cachedResult);
return cachedResult;
}
const ndk = get(ndkInstance);
if (!ndk) {
console.error("subscription_search: NDK not initialized");
throw new Error('NDK not initialized');
}
console.log("subscription_search: NDK initialized, creating search state");
const searchState = createSearchState();
const cleanup = createCleanupFunction(searchState);
// Set a timeout to force completion after subscription search timeout
searchState.timeoutId = setTimeout(() => {
console.log("subscription_search: Search timeout reached");
cleanup();
}, TIMEOUTS.SUBSCRIPTION_SEARCH);
// Check for abort signal
if (abortSignal?.aborted) {
console.log("subscription_search: Search aborted");
cleanup();
throw new Error('Search cancelled');
}
const searchFilter = await createSearchFilter(searchType, normalizedSearchTerm);
console.log("subscription_search: Created search filter:", searchFilter);
const primaryRelaySet = createPrimaryRelaySet(searchType, ndk);
console.log("subscription_search: Created primary relay set with", primaryRelaySet.relays.size, "relays");
// Phase 1: Search primary relay
if (primaryRelaySet.relays.size > 0) {
try {
console.log("subscription_search: Searching primary relay with filter:", searchFilter.filter);
const primaryEvents = await ndk.fetchEvents(
searchFilter.filter,
{ closeOnEose: true },
primaryRelaySet
);
console.log("subscription_search: Primary relay returned", primaryEvents.size, "events");
processPrimaryRelayResults(primaryEvents, searchType, searchFilter.subscriptionType, normalizedSearchTerm, searchState, abortSignal, cleanup);
// If we found results from primary relay, return them immediately
if (hasResults(searchState, searchType)) {
console.log("subscription_search: Found results from primary relay, returning immediately");
const immediateResult = createSearchResult(searchState, searchType, normalizedSearchTerm);
searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// Start Phase 2 in background for additional results
searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup);
return immediateResult;
} else {
console.log("subscription_search: No results from primary relay, continuing to Phase 2");
}
} catch (error) {
console.error(`subscription_search: Error searching primary relay:`, error);
}
} else {
console.log("subscription_search: No primary relays available, skipping Phase 1");
}
// Always do Phase 2: Search all other relays in parallel
return searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup);
}
/**
* Create search state object
*/
function createSearchState() {
return {
timeoutId: null as ReturnType<typeof setTimeout> | null,
firstOrderEvents: [] as NDKEvent[],
secondOrderEvents: [] as NDKEvent[],
tTagEvents: [] as NDKEvent[],
eventIds: new Set<string>(),
eventAddresses: new Set<string>(),
foundProfiles: [] as NDKEvent[],
isCompleted: false,
currentSubscription: null as any
};
}
/**
* Create cleanup function
*/
function createCleanupFunction(searchState: any) {
return () => {
if (searchState.timeoutId) {
clearTimeout(searchState.timeoutId);
searchState.timeoutId = null;
}
if (searchState.currentSubscription) {
try {
searchState.currentSubscription.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
searchState.currentSubscription = null;
}
};
}
/**
* Create search filter based on search type
*/
async function createSearchFilter(searchType: SearchSubscriptionType, normalizedSearchTerm: string): Promise<SearchFilter> {
console.log("subscription_search: Creating search filter for:", { searchType, normalizedSearchTerm });
switch (searchType) {
case 'd':
const dFilter = {
filter: { "#d": [normalizedSearchTerm] },
subscriptionType: 'd-tag'
};
console.log("subscription_search: Created d-tag filter:", dFilter);
return dFilter;
case 't':
const tFilter = {
filter: { "#t": [normalizedSearchTerm] },
subscriptionType: 't-tag'
};
console.log("subscription_search: Created t-tag filter:", tFilter);
return tFilter;
case 'n':
const nFilter = await createProfileSearchFilter(normalizedSearchTerm);
console.log("subscription_search: Created profile filter:", nFilter);
return nFilter;
default:
throw new Error(`Unknown search type: ${searchType}`);
}
}
/**
* Create profile search filter
*/
async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<SearchFilter> {
// For npub searches, try to decode the search term first
try {
const decoded = nip19.decode(normalizedSearchTerm);
if (decoded && decoded.type === 'npub') {
return {
filter: { kinds: [0], authors: [decoded.data], limit: SEARCH_LIMITS.SPECIFIC_PROFILE },
subscriptionType: 'npub-specific'
};
}
} catch (e) {
// Not a valid npub, continue with other strategies
}
// Try NIP-05 lookup first
try {
for (const domain of COMMON_DOMAINS) {
const nip05Address = `${normalizedSearchTerm}@${domain}`;
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
return {
filter: { kinds: [0], authors: [npub], limit: SEARCH_LIMITS.SPECIFIC_PROFILE },
subscriptionType: 'nip05-found'
};
}
} catch (e) {
// Continue to next domain
}
}
} catch (e) {
// Fallback to reasonable profile search
}
return {
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE },
subscriptionType: 'profile'
};
}
/**
* Create primary relay set based on search type
*/
function createPrimaryRelaySet(searchType: SearchSubscriptionType, ndk: any): NDKRelaySet {
if (searchType === 'n') {
// For profile searches, use profile relay first
const profileRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
relay.url === profileRelay || relay.url === profileRelay + '/'
);
return new NDKRelaySet(new Set(profileRelays) as any, ndk);
} else {
// For other searches, use community relay first
const communityRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
relay.url === communityRelay || relay.url === communityRelay + '/'
);
return new NDKRelaySet(new Set(communityRelays) as any, ndk);
}
}
/**
* Process primary relay results
*/
function processPrimaryRelayResults(
events: Set<NDKEvent>,
searchType: SearchSubscriptionType,
subscriptionType: string,
normalizedSearchTerm: string,
searchState: any,
abortSignal?: AbortSignal,
cleanup?: () => void
) {
console.log("subscription_search: Processing", events.size, "events from primary relay");
for (const event of events) {
// Check for abort signal
if (abortSignal?.aborted) {
cleanup?.();
throw new Error('Search cancelled');
}
try {
if (searchType === 'n') {
processProfileEvent(event, subscriptionType, normalizedSearchTerm, searchState);
} else {
processContentEvent(event, searchType, searchState);
}
} catch (e) {
console.warn("subscription_search: Error processing event:", e);
// Invalid JSON or other error, skip
}
}
console.log("subscription_search: Processed events - firstOrder:", searchState.firstOrderEvents.length, "profiles:", searchState.foundProfiles.length, "tTag:", searchState.tTagEvents.length);
}
/**
* Process profile event
*/
function processProfileEvent(event: NDKEvent, subscriptionType: string, normalizedSearchTerm: string, searchState: any) {
if (!event.content) return;
// If this is a specific npub search or NIP-05 found search, include all matching events
if (subscriptionType === 'npub-specific' || subscriptionType === 'nip05-found') {
searchState.foundProfiles.push(event);
return;
}
// For general profile searches, filter by content
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || '';
const name = profileData.name || '';
const nip05 = profileData.nip05 || '';
const username = profileData.username || '';
const about = profileData.about || '';
const bio = profileData.bio || '';
const description = profileData.description || '';
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesUsername = fieldMatches(username, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
const matchesBio = fieldMatches(bio, normalizedSearchTerm);
const matchesDescription = fieldMatches(description, normalizedSearchTerm);
if (matchesDisplayName || matchesName || matchesNip05 || matchesUsername || matchesAbout || matchesBio || matchesDescription) {
searchState.foundProfiles.push(event);
}
}
/**
* Process content event
*/
function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType, searchState: any) {
if (isEmojiReaction(event)) return; // Skip emoji reactions
if (searchType === 'd') {
console.log("subscription_search: Processing d-tag event:", { id: event.id, kind: event.kind, pubkey: event.pubkey });
searchState.firstOrderEvents.push(event);
// Collect event IDs and addresses for second-order search
if (event.id) {
searchState.eventIds.add(event.id);
}
const aTags = getMatchingTags(event, "a");
aTags.forEach((tag: string[]) => {
if (tag[1]) {
searchState.eventAddresses.add(tag[1]);
}
});
} else if (searchType === 't') {
searchState.tTagEvents.push(event);
}
}
/**
* Check if search state has results
*/
function hasResults(searchState: any, searchType: SearchSubscriptionType): boolean {
if (searchType === 'n') {
return searchState.foundProfiles.length > 0;
} else if (searchType === 'd') {
return searchState.firstOrderEvents.length > 0;
} else if (searchType === 't') {
return searchState.tTagEvents.length > 0;
}
return false;
}
/**
* Create search result from state
*/
function createSearchResult(searchState: any, searchType: SearchSubscriptionType, normalizedSearchTerm: string): SearchResult {
return {
events: searchType === 'n' ? searchState.foundProfiles : searchState.firstOrderEvents,
secondOrder: [],
tTagEvents: searchType === 't' ? searchState.tTagEvents : [],
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
searchTerm: normalizedSearchTerm
};
}
/**
* Search other relays in background
*/
async function searchOtherRelaysInBackground(
searchType: SearchSubscriptionType,
searchFilter: SearchFilter,
searchState: any,
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal,
cleanup?: () => void
): Promise<SearchResult> {
const ndk = get(ndkInstance);
const otherRelays = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values()).filter((relay: any) => {
if (searchType === 'n') {
// For profile searches, exclude profile relay from fallback search
return relay.url !== profileRelay && relay.url !== profileRelay + '/';
} else {
// For other searches, exclude community relay from fallback search
return relay.url !== communityRelay && relay.url !== communityRelay + '/';
}
})),
ndk
);
// Subscribe to events from other relays
const sub = ndk.subscribe(
searchFilter.filter,
{ closeOnEose: true },
otherRelays
);
// Store the subscription for cleanup
searchState.currentSubscription = sub;
// Notify the component about the subscription for cleanup
if (callbacks?.onSubscriptionCreated) {
callbacks.onSubscriptionCreated(sub);
}
sub.on('event', (event: NDKEvent) => {
try {
if (searchType === 'n') {
processProfileEvent(event, searchFilter.subscriptionType, searchState.normalizedSearchTerm, searchState);
} else {
processContentEvent(event, searchType, searchState);
}
} catch (e) {
// Invalid JSON or other error, skip
}
});
return new Promise<SearchResult>((resolve) => {
sub.on('eose', () => {
const result = processEoseResults(searchType, searchState, searchFilter, callbacks);
searchCache.set(searchType, searchState.normalizedSearchTerm, result);
cleanup?.();
resolve(result);
});
});
}
/**
* Process EOSE results
*/
function processEoseResults(
searchType: SearchSubscriptionType,
searchState: any,
searchFilter: SearchFilter,
callbacks?: SearchCallbacks
): SearchResult {
if (searchType === 'n') {
return processProfileEoseResults(searchState, searchFilter, callbacks);
} else if (searchType === 'd') {
return processContentEoseResults(searchState, searchType);
} else if (searchType === 't') {
return processTTagEoseResults(searchState);
}
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
}
/**
* Process profile EOSE results
*/
function processProfileEoseResults(searchState: any, searchFilter: SearchFilter, callbacks?: SearchCallbacks): SearchResult {
if (searchState.foundProfiles.length === 0) {
return createEmptySearchResult('n', searchState.normalizedSearchTerm);
}
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of searchState.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 };
}
}
// Sort by creation time (newest first) and take only the most recent profiles
const dedupedProfiles = Object.values(deduped)
.sort((a, b) => b.created_at - a.created_at)
.map(x => x.event);
// Perform second-order search for npub searches
if (searchFilter.subscriptionType === 'npub-specific' || searchFilter.subscriptionType === 'nip05-found') {
const targetPubkey = dedupedProfiles[0]?.pubkey;
if (targetPubkey) {
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), targetPubkey, callbacks);
}
} else if (searchFilter.subscriptionType === 'profile') {
// For general profile searches, perform second-order search for each found profile
for (const profile of dedupedProfiles) {
if (profile.pubkey) {
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), profile.pubkey, callbacks);
}
}
}
return {
events: dedupedProfiles,
secondOrder: [],
tTagEvents: [],
eventIds: new Set(dedupedProfiles.map(p => p.id)),
addresses: new Set(),
searchType: 'n',
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Process content EOSE results
*/
function processContentEoseResults(searchState: any, searchType: SearchSubscriptionType): SearchResult {
if (searchState.firstOrderEvents.length === 0) {
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
}
// 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 searchState.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);
// Perform second-order search for d-tag searches
if (dedupedEvents.length > 0) {
performSecondOrderSearchInBackground('d', dedupedEvents, searchState.eventIds, searchState.eventAddresses);
}
return {
events: dedupedEvents,
secondOrder: [],
tTagEvents: [],
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Process t-tag EOSE results
*/
function processTTagEoseResults(searchState: any): SearchResult {
if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult('t', searchState.normalizedSearchTerm);
}
return {
events: [],
secondOrder: [],
tTagEvents: searchState.tTagEvents,
eventIds: new Set(),
addresses: new Set(),
searchType: 't',
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Create empty search result
*/
function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm: string): SearchResult {
return {
events: [],
secondOrder: [],
tTagEvents: [],
eventIds: new Set(),
addresses: new Set(),
searchType: searchType,
searchTerm: searchTerm
};
}
/**
* Perform second-order search in background
*/
async function performSecondOrderSearchInBackground(
searchType: 'n' | 'd',
firstOrderEvents: NDKEvent[],
eventIds: Set<string> = new Set(),
addresses: Set<string> = new Set(),
targetPubkey?: string,
callbacks?: SearchCallbacks
) {
try {
const ndk = get(ndkInstance);
let allSecondOrderEvents: NDKEvent[] = [];
if (searchType === 'n' && targetPubkey) {
// Search for events that mention this pubkey via p-tags
const pTagFilter = { "#p": [targetPubkey] };
const pTagEvents = await ndk.fetchEvents(
pTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
// Filter out emoji reactions
const filteredEvents = Array.from(pTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents];
} else if (searchType === 'd') {
// Search for events that reference the original events via e-tags and a-tags
// Search for events that reference the original events via e-tags
if (eventIds.size > 0) {
const eTagFilter = { "#e": Array.from(eventIds) };
const eTagEvents = await ndk.fetchEvents(
eTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
// Filter out emoji reactions
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents];
}
// Search for events that reference the original events via a-tags
if (addresses.size > 0) {
const aTagFilter = { "#a": Array.from(addresses) };
const aTagEvents = await ndk.fetchEvents(
aTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
// Filter out emoji reactions
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredATagEvents];
}
}
// Deduplicate by event ID
const uniqueSecondOrder = new Map<string, NDKEvent>();
allSecondOrderEvents.forEach(event => {
if (event.id) {
uniqueSecondOrder.set(event.id, event);
}
});
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values());
// Remove any events already in first order
const firstOrderIds = new Set(firstOrderEvents.map(e => e.id));
deduplicatedSecondOrder = deduplicatedSecondOrder.filter(e => !firstOrderIds.has(e.id));
// Sort by creation date (newest first) and limit to newest results
const sortedSecondOrder = deduplicatedSecondOrder
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS);
// Update the search results with second-order events
const result: SearchResult = {
events: firstOrderEvents,
secondOrder: sortedSecondOrder,
tTagEvents: [],
eventIds: searchType === 'n' ? new Set(firstOrderEvents.map(p => p.id)) : eventIds,
addresses: searchType === 'n' ? new Set() : addresses,
searchType: searchType,
searchTerm: '' // This will be set by the caller
};
// Notify UI of updated results
if (callbacks?.onSecondOrderUpdate) {
callbacks.onSecondOrderUpdate(result);
}
} catch (err) {
console.error(`[Search] Error in second-order ${searchType}-tag search:`, err);
}
}

405
src/routes/events/+page.svelte

@ -11,12 +11,14 @@
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 EventInput from '$lib/components/EventInput.svelte';
import { userPubkey, isLoggedIn } from '$lib/stores/authStore'; import { userPubkey, isLoggedIn } from '$lib/stores/authStore.Svelte';
import { testAllRelays, logRelayDiagnostics } from '$lib/utils/relayDiagnostics'; import { testAllRelays, logRelayDiagnostics } from '$lib/utils/relayDiagnostics';
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte'; import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte';
import { neventEncode, naddrEncode } from '$lib/utils'; import { neventEncode, naddrEncode } from '$lib/utils';
import { standardRelays } from '$lib/consts'; import { standardRelays } from '$lib/consts';
import { getEventType } from '$lib/utils/mime'; import { getEventType } from '$lib/utils/mime';
import ViewPublicationLink from '$lib/components/util/ViewPublicationLink.svelte';
import { checkCommunity } from '$lib/utils/search_utility';
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -28,6 +30,8 @@
let tTagResults = $state<NDKEvent[]>([]); let tTagResults = $state<NDKEvent[]>([]);
let originalEventIds = $state<Set<string>>(new Set()); let originalEventIds = $state<Set<string>>(new Set());
let originalAddresses = $state<Set<string>>(new Set()); let originalAddresses = $state<Set<string>>(new Set());
let searchType = $state<string | null>(null);
let searchTerm = $state<string | null>(null);
let profile = $state<{ let profile = $state<{
name?: string; name?: string;
display_name?: string; display_name?: string;
@ -39,14 +43,25 @@
nip05?: string; nip05?: string;
} | null>(null); } | null>(null);
let userRelayPreference = $state(false); let userRelayPreference = $state(false);
let showSidePanel = $state(false);
let searchInProgress = $state(false);
let secondOrderSearchMessage = $state<string | null>(null);
let communityStatus = $state<Record<string, boolean>>({});
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
showSidePanel = true;
// Clear search results when showing a single event
searchResults = []; searchResults = [];
secondOrderResults = []; secondOrderResults = [];
tTagResults = []; tTagResults = [];
originalEventIds = new Set(); originalEventIds = new Set();
originalAddresses = new Set(); originalAddresses = new Set();
searchType = null;
searchTerm = null;
searchInProgress = false;
secondOrderSearchMessage = null;
if (newEvent.kind === 0) { if (newEvent.kind === 0) {
try { try {
profile = JSON.parse(newEvent.content); profile = JSON.parse(newEvent.content);
@ -58,20 +73,72 @@
} }
} }
function handleSearchResults(results: NDKEvent[], secondOrder: NDKEvent[] = [], tTagEvents: NDKEvent[] = [], eventIds: Set<string> = new Set(), addresses: Set<string> = new Set()) { function handleSearchResults(results: NDKEvent[], secondOrder: NDKEvent[] = [], tTagEvents: NDKEvent[] = [], eventIds: Set<string> = new Set(), addresses: Set<string> = new Set(), searchTypeParam?: string, searchTermParam?: string) {
searchResults = results; searchResults = results;
secondOrderResults = secondOrder; secondOrderResults = secondOrder;
tTagResults = tTagEvents; tTagResults = tTagEvents;
originalEventIds = eventIds; originalEventIds = eventIds;
originalAddresses = addresses; originalAddresses = addresses;
event = null; searchType = searchTypeParam || null;
profile = null; searchTerm = searchTermParam || null;
// Track search progress
searchInProgress = loading || (results.length > 0 && secondOrder.length === 0);
// Show second-order search message when we have first-order results but no second-order yet
if (results.length > 0 && secondOrder.length === 0 && searchTypeParam === 'n') {
secondOrderSearchMessage = `Found ${results.length} profile(s). Starting second-order search for events mentioning these profiles...`;
} else if (results.length > 0 && secondOrder.length === 0 && searchTypeParam === 'd') {
secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`;
} else if (secondOrder.length > 0) {
secondOrderSearchMessage = null;
}
// Check community status for all search results
if (results.length > 0) {
checkCommunityStatusForResults(results);
}
if (secondOrder.length > 0) {
checkCommunityStatusForResults(secondOrder);
}
if (tTagEvents.length > 0) {
checkCommunityStatusForResults(tTagEvents);
}
// Don't clear the current event - let the user continue viewing it
// event = null;
// profile = null;
} }
function handleClear() { function handleClear() {
searchType = null;
searchTerm = null;
searchResults = [];
secondOrderResults = [];
tTagResults = [];
originalEventIds = new Set();
originalAddresses = new Set();
event = null;
profile = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
communityStatus = {};
goto('/events', { replaceState: true }); goto('/events', { replaceState: true });
} }
function closeSidePanel() {
showSidePanel = false;
event = null;
profile = null;
searchInProgress = false;
secondOrderSearchMessage = null;
}
function navigateToPublication(dTag: string) {
goto(`/publications?d=${encodeURIComponent(dTag.toLowerCase())}`);
}
function getSummary(event: NDKEvent): string | undefined { function getSummary(event: NDKEvent): string | undefined {
return getMatchingTags(event, "summary")[0]?.[1]; return getMatchingTags(event, "summary")[0]?.[1];
} }
@ -138,6 +205,17 @@
} }
} }
function getViewPublicationNaddr(event: NDKEvent): string | null {
// For deferred events, use the deferral naddr instead of the event's own naddr
const deferralNaddr = getDeferralNaddr(event);
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function shortenAddress(addr: string, head = 10, tail = 10): string { function shortenAddress(addr: string, head = 10, tail = 10): string {
if (!addr || addr.length <= head + tail + 3) return addr; if (!addr || addr.length <= head + tail + 3) return addr;
return addr.slice(0, head) + '…' + addr.slice(-tail); return addr.slice(0, head) + '…' + addr.slice(-tail);
@ -145,21 +223,58 @@
function onLoadingChange(val: boolean) { function onLoadingChange(val: boolean) {
loading = val; loading = val;
searchInProgress = val || (searchResults.length > 0 && secondOrderResults.length === 0);
} }
$effect(() => { /**
* Check community status for all search results
*/
async function checkCommunityStatusForResults(events: NDKEvent[]) {
const newCommunityStatus: Record<string, boolean> = {};
for (const event of events) {
if (event.pubkey && !communityStatus[event.pubkey]) {
try {
newCommunityStatus[event.pubkey] = await checkCommunity(event.pubkey);
} catch (error) {
console.error('Error checking community status for', event.pubkey, error);
newCommunityStatus[event.pubkey] = false;
}
} else if (event.pubkey) {
newCommunityStatus[event.pubkey] = communityStatus[event.pubkey];
}
}
communityStatus = { ...communityStatus, ...newCommunityStatus };
}
function updateSearchFromURL() {
const id = $page.url.searchParams.get("id"); const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d"); const dTag = $page.url.searchParams.get("d");
console.log("Events page URL update:", { id, dTag, searchValue });
if (id !== searchValue) { if (id !== searchValue) {
console.log("ID changed, updating searchValue:", { old: searchValue, new: id });
searchValue = id; searchValue = id;
dTagValue = null; dTagValue = null;
// Only close side panel if we're clearing the search
if (!id) {
showSidePanel = false;
event = null;
profile = null;
}
} }
if (dTag !== dTagValue) { if (dTag !== dTagValue) {
console.log("DTag changed, updating dTagValue:", { old: dTagValue, new: dTag });
// Normalize d-tag to lowercase for consistent searching // Normalize d-tag to lowercase for consistent searching
dTagValue = dTag ? dTag.toLowerCase() : null; dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null; searchValue = null;
// For d-tag searches (which return multiple results), close side panel
showSidePanel = false;
event = null;
profile = null;
} }
// Reset state if both id and dTag are absent // Reset state if both id and dTag are absent
@ -167,7 +282,62 @@
event = null; event = null;
searchResults = []; searchResults = [];
profile = null; profile = null;
searchType = null;
searchTerm = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
} }
}
// Force search when URL changes
function handleUrlChange() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
console.log("Events page URL change:", { id, dTag, currentSearchValue: searchValue, currentDTagValue: dTagValue });
// Handle ID parameter changes
if (id !== searchValue) {
console.log("ID parameter changed:", { old: searchValue, new: id });
searchValue = id;
dTagValue = null;
if (!id) {
showSidePanel = false;
event = null;
profile = null;
}
}
// Handle d-tag parameter changes
if (dTag !== dTagValue) {
console.log("d-tag parameter changed:", { old: dTagValue, new: dTag });
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
showSidePanel = false;
event = null;
profile = null;
}
// Reset state if both parameters are absent
if (!id && !dTag) {
console.log("Both ID and d-tag parameters absent, resetting state");
event = null;
searchResults = [];
profile = null;
searchType = null;
searchTerm = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
searchValue = null;
dTagValue = null;
}
}
// Listen for URL changes
$effect(() => {
handleUrlChange();
}); });
onMount(() => { onMount(() => {
@ -179,9 +349,20 @@
</script> </script>
<div class="w-full flex justify-center"> <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 mx-auto"> <div class="flex w-full max-w-7xl my-6 px-4 mx-auto gap-6">
<!-- Left Panel: Search and Results -->
<div class={showSidePanel ? "w-80 min-w-80" : "flex-1 max-w-4xl mx-auto"}>
<div class="main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading> <Heading tag="h1" class="h-leather mb-2">Events</Heading>
{#if showSidePanel}
<button
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
onclick={closeSidePanel}
>
Close Details
</button>
{/if}
</div> </div>
<P class="mb-3"> <P class="mb-3">
@ -202,43 +383,22 @@
onLoadingChange={onLoadingChange} onLoadingChange={onLoadingChange}
/> />
{#if event} {#if secondOrderSearchMessage}
{#if event.kind !== 0} <div class="mt-4 p-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg">
<div class="flex flex-col gap-2 mb-4 break-all"> {secondOrderSearchMessage}
<CopyToClipboard
displayText={shortenAddress(getNeventAddress(event))}
copyText={getNeventAddress(event)}
/>
{#if isAddressableEvent(event)}
{@const naddrAddress = getNaddrAddress(event)}
{#if naddrAddress}
<CopyToClipboard
displayText={shortenAddress(naddrAddress)}
copyText={naddrAddress}
/>
{/if}
{/if}
</div> </div>
{/if} {/if}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
{#if $isLoggedIn && $userPubkey}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">
<P>Please sign in to add comments.</P>
</div>
{/if}
{/if}
{#if searchResults.length > 0} {#if searchResults.length > 0}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4"> <Heading tag="h2" class="h-leather mb-4">
Search Results for d-tag: "{dTagValue?.toLowerCase()}" ({searchResults.length} {#if searchType === 'n'}
events) Search Results for name: "{searchTerm}" ({searchResults.length} profiles)
{:else if searchType === 't'}
Search Results for t-tag: "{searchTerm}" ({searchResults.length} events)
{:else}
Search Results for d-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({searchResults.length} events)
{/if}
</Heading> </Heading>
<div class="space-y-4"> <div class="space-y-4">
{#each searchResults as result, index} {#each searchResults as result, index}
@ -249,11 +409,20 @@
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<span class="font-medium text-gray-800 dark:text-gray-100" <span class="font-medium text-gray-800 dark:text-gray-100"
>Event {index + 1}</span >{searchType === 'n' ? 'Profile' : 'Event'} {index + 1}</span
> >
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge( {@render userBadge(
toNpub(result.pubkey) as string, toNpub(result.pubkey) as string,
@ -280,15 +449,29 @@
class="text-xs text-primary-800 dark:text-primary-300 mb-1" class="text-xs text-primary-800 dark:text-primary-300 mb-1"
> >
Read Read
<a <span
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all" class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
href={"/publications?d=" + onclick={(e) => {
encodeURIComponent((dTagValue || "").toLowerCase())} e.stopPropagation();
onclick={(e) => e.stopPropagation()} navigateToPublication(getDeferralNaddr(result) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0" tabindex="0"
role="button"
> >
{getDeferralNaddr(result)} {getDeferralNaddr(result)}
</a> </span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div> </div>
{/if} {/if}
{#if result.content} {#if result.content}
@ -313,6 +496,11 @@
Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length} Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length}
events) events)
</Heading> </Heading>
{#if (searchType === 'n' || searchType === 'd') && secondOrderResults.length === 100}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing the 100 newest events. More results may be available.
</P>
{/if}
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that reference, reply to, highlight, or quote the original events. Events that reference, reply to, highlight, or quote the original events.
</P> </P>
@ -330,6 +518,15 @@
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge( {@render userBadge(
toNpub(result.pubkey) as string, toNpub(result.pubkey) as string,
@ -359,15 +556,29 @@
class="text-xs text-primary-800 dark:text-primary-300 mb-1" class="text-xs text-primary-800 dark:text-primary-300 mb-1"
> >
Read Read
<a <span
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all" class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
href={"/publications?d=" + onclick={(e) => {
encodeURIComponent((dTagValue || "").toLowerCase())} e.stopPropagation();
onclick={(e) => e.stopPropagation()} navigateToPublication(getDeferralNaddr(result) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0" tabindex="0"
role="button"
> >
{getDeferralNaddr(result)} {getDeferralNaddr(result)}
</a> </span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div> </div>
{/if} {/if}
{#if result.content} {#if result.content}
@ -389,8 +600,7 @@
{#if tTagResults.length > 0} {#if tTagResults.length > 0}
<div class="mt-8"> <div class="mt-8">
<Heading tag="h2" class="h-leather mb-4"> <Heading tag="h2" class="h-leather mb-4">
Search Results for t-tag: "{dTagValue?.toLowerCase()}" ({tTagResults.length} Search Results for t-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({tTagResults.length} events)
events)
</Heading> </Heading>
<P class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <P class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Events that are tagged with the t-tag. Events that are tagged with the t-tag.
@ -409,6 +619,15 @@
<span class="text-xs text-gray-600 dark:text-gray-400" <span class="text-xs text-gray-600 dark:text-gray-400"
>Kind: {result.kind}</span >Kind: {result.kind}</span
> >
{#if result.pubkey && communityStatus[result.pubkey]}
<div class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" title="Has posted to the community">
<svg class="w-3 h-3 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400"> <span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge( {@render userBadge(
toNpub(result.pubkey) as string, toNpub(result.pubkey) as string,
@ -435,15 +654,29 @@
class="text-xs text-primary-800 dark:text-primary-300 mb-1" class="text-xs text-primary-800 dark:text-primary-300 mb-1"
> >
Read Read
<a <span
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all" class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer"
href={"/publications?d=" + onclick={(e) => {
encodeURIComponent((dTagValue || "").toLowerCase())} e.stopPropagation();
onclick={(e) => e.stopPropagation()} navigateToPublication(getDeferralNaddr(result) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0" tabindex="0"
role="button"
> >
{getDeferralNaddr(result)} {getDeferralNaddr(result)}
</a> </span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div> </div>
{/if} {/if}
{#if result.content} {#if result.content}
@ -462,10 +695,62 @@
</div> </div>
{/if} {/if}
{#if !event && searchResults.length === 0 && secondOrderResults.length === 0 && tTagResults.length === 0 && !searchValue && !dTagValue} {#if !event && searchResults.length === 0 && secondOrderResults.length === 0 && tTagResults.length === 0 && !searchValue && !dTagValue && !searchInProgress}
<div class="mt-8"> <div class="mt-8">
<EventInput /> <EventInput />
</div> </div>
{/if} {/if}
</main> </div>
</div>
<!-- Right Panel: Event Details -->
{#if showSidePanel && event}
<div class="flex-1 min-w-0 main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center">
<Heading tag="h2" class="h-leather mb-2">Event Details</Heading>
<button
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
onclick={closeSidePanel}
>
</button>
</div>
{#if event.kind !== 0}
<div class="flex flex-col gap-2 mb-4 break-all">
<CopyToClipboard
displayText={shortenAddress(getNeventAddress(event))}
copyText={getNeventAddress(event)}
/>
{#if isAddressableEvent(event)}
{@const naddrAddress = getViewPublicationNaddr(event)}
{#if naddrAddress}
<CopyToClipboard
displayText={shortenAddress(naddrAddress)}
copyText={naddrAddress}
/>
<div class="mt-2">
<ViewPublicationLink {event} />
</div>
{/if}
{/if}
</div>
{/if}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
{#if isLoggedIn && userPubkey}
<div class="mt-8">
<Heading tag="h3" class="h-leather mb-4">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">
<P>Please sign in to add comments.</P>
</div>
{/if}
</div>
{/if}
</div>
</div> </div>

Loading…
Cancel
Save