+
+ {#if showError}
+
{localError || error}
{#if searchQuery.trim()}
@@ -706,20 +552,30 @@
class="underline text-primary-700"
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())}
target="_blank"
- rel="noopener">Njump.
+ rel="noopener"
+ >
+ Njump
+ .
{/if}
{/if}
+
+ {#if showSuccess}
+
+ {getResultMessage()}
+
+ {/if}
+
+
{#each Object.entries(relayStatuses) as [relay, status]}
{/each}
- {#if !foundEvent && Object.values(relayStatuses).some((s) => s === "pending")}
+ {#if !foundEvent && hasActiveSearch}
Searching relays...
diff --git a/src/lib/components/LoginModal.svelte b/src/lib/components/LoginModal.svelte
index b52d44e..bb9d97c 100644
--- a/src/lib/components/LoginModal.svelte
+++ b/src/lib/components/LoginModal.svelte
@@ -1,5 +1,5 @@
-{#if show}
-
-
+
+
+ You need to be logged in to submit an issue. Your form data will be
+ preserved.
+
+
+
+
+
+ {#if signInFailed}
-
-
-
- Login Required
-
-
-
-
-
-
-
- You need to be logged in to submit an issue. Your form data will be
- preserved.
-
-
-
-
-
- {#if signInFailed}
-
- {errorMessage}
-
- {/if}
-
-
+ {errorMessage}
-
+ {/if}
-{/if}
+
diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte
deleted file mode 100644
index b9b6e14..0000000
--- a/src/lib/components/Modal.svelte
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-{#if showModal}
-
-{/if}
diff --git a/src/lib/components/PublicationFeed.svelte b/src/lib/components/PublicationFeed.svelte
index 62d4fc3..309cace 100644
--- a/src/lib/components/PublicationFeed.svelte
+++ b/src/lib/components/PublicationFeed.svelte
@@ -11,6 +11,10 @@
type NDKEvent,
type NDKRelaySet,
} 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 {
relays,
@@ -45,6 +49,18 @@
(r: string) => !primaryRelays.includes(r),
);
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(
allRelays.map((r: string) => [r, "pending"]),
);
@@ -91,6 +107,10 @@
allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
+
+ // Cache the fetched events
+ indexEventCache.set(allRelays, allIndexEvents);
+
// Initially show first page
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
@@ -108,8 +128,15 @@
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
- const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query);
+ const isNip05Query = isValidNip05Address(query);
console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
const filtered = events.filter((event) => {
@@ -151,6 +178,19 @@
}
return matches;
});
+
+ // Cache the filtered results
+ const result = {
+ events: filtered,
+ secondOrder: [],
+ tTagEvents: [],
+ eventIds: new Set
(),
+ addresses: new Set(),
+ searchType: 'publication',
+ searchTerm: query
+ };
+ searchCache.set('publication', query, result);
+
console.debug("[PublicationFeed] Events after filtering:", filtered.length);
return filtered;
};
@@ -197,6 +237,30 @@
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 () => {
await fetchAllIndexEventsFromRelays();
});
@@ -217,6 +281,7 @@
{/if}
+
{#if !loadingMore && !endOfFeed}
@@ -127,11 +155,16 @@
{id.label}:
- {#if id.link}{id.value}{:else}{id.value}{/if}
+ {#if id.link}
+
+ {:else}
+ {id.value}
+ {/if}
{/each}
diff --git a/src/lib/components/util/CardActions.svelte b/src/lib/components/util/CardActions.svelte
index af030e3..6ee8b14 100644
--- a/src/lib/components/util/CardActions.svelte
+++ b/src/lib/components/util/CardActions.svelte
@@ -1,56 +1,47 @@
Identifier: {identifier}
{/if}
-
View Event Details
-
+
diff --git a/src/lib/components/util/Profile.svelte b/src/lib/components/util/Profile.svelte
index 77dbaf9..16878ef 100644
--- a/src/lib/components/util/Profile.svelte
+++ b/src/lib/components/util/Profile.svelte
@@ -8,8 +8,7 @@
} from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
-
- const externalProfileDestination = "./events?id=";
+ import { goto } from "$app/navigation";
let { pubkey, isNav = false } = $props();
@@ -34,6 +33,12 @@
profile = null;
}
+ function handleViewProfile() {
+ if (npub) {
+ goto(`/events?id=${encodeURIComponent(npub)}`);
+ }
+ }
+
function shortenNpub(long: string | undefined) {
if (!long) return "";
return long.slice(0, 8) + "…" + long.slice(-4);
@@ -71,14 +76,14 @@
/>
-
View profile
-
+
{#if isNav}
diff --git a/src/lib/components/util/ViewPublicationLink.svelte b/src/lib/components/util/ViewPublicationLink.svelte
new file mode 100644
index 0000000..f13fcbf
--- /dev/null
+++ b/src/lib/components/util/ViewPublicationLink.svelte
@@ -0,0 +1,80 @@
+
+
+{#if naddrAddress}
+
+{/if}
\ No newline at end of file
diff --git a/src/lib/consts.ts b/src/lib/consts.ts
index 86bb122..b1e4b3c 100644
--- a/src/lib/consts.ts
+++ b/src/lib/consts.ts
@@ -1,7 +1,8 @@
export const wikiKind = 30818;
export const indexKind = 30040;
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 = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",
diff --git a/src/lib/ndk.ts b/src/lib/ndk.ts
index ff130dd..e4fb934 100644
--- a/src/lib/ndk.ts
+++ b/src/lib/ndk.ts
@@ -15,7 +15,7 @@ import {
anonymousRelays,
} from "./consts";
import { feedType } from "./stores";
-import { userPubkey } from '$lib/stores/authStore';
+import { userPubkey } from '$lib/stores/authStore.Svelte';
export const ndkInstance: Writable = writable();
diff --git a/src/lib/snippets/UserSnippets.svelte b/src/lib/snippets/UserSnippets.svelte
index 8a0774b..b8a79fc 100644
--- a/src/lib/snippets/UserSnippets.svelte
+++ b/src/lib/snippets/UserSnippets.svelte
@@ -1,22 +1,31 @@
{#snippet userBadge(identifier: string, displayText: string | undefined)}
{#if toNpub(identifier)}
- {#await createProfileLinkWithVerification(toNpub(identifier) as string, displayText)}
- {@html createProfileLink(toNpub(identifier) as string, displayText)}
- {:then html}
- {@html html}
- {:catch}
- {@html createProfileLink(toNpub(identifier) as string, displayText)}
- {/await}
+ {@const npub = toNpub(identifier) as string}
+ {@const cleanId = npub.replace(/^nostr:/, "")}
+ {@const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`}
+ {@const displayTextFinal = displayText || defaultText}
+
+
{:else}
{displayText ?? ""}
{/if}
diff --git a/src/lib/stores/authStore.ts b/src/lib/stores/authStore.Svelte.ts
similarity index 100%
rename from src/lib/stores/authStore.ts
rename to src/lib/stores/authStore.Svelte.ts
diff --git a/src/lib/utils/community_checker.ts b/src/lib/utils/community_checker.ts
new file mode 100644
index 0000000..6f906e7
--- /dev/null
+++ b/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();
+
+/**
+ * Check if a pubkey has posted to the community relay
+ */
+export async function checkCommunity(pubkey: string): Promise {
+ 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> {
+ const communityStatus: Record = {};
+
+ for (const profile of profiles) {
+ if (profile.pubkey) {
+ communityStatus[profile.pubkey] = await checkCommunity(profile.pubkey);
+ }
+ }
+
+ return communityStatus;
+}
\ No newline at end of file
diff --git a/src/lib/utils/event_input_utils.ts b/src/lib/utils/event_input_utils.ts
index a22fef0..f248359 100644
--- a/src/lib/utils/event_input_utils.ts
+++ b/src/lib/utils/event_input_utils.ts
@@ -2,6 +2,7 @@ import type { NDKEvent } from './nostrUtils';
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
+import { EVENT_KINDS } from './search_constants';
// =========================
// 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).
*/
export function requiresDTag(kind: number): boolean {
- return kind >= 30000 && kind <= 39999;
+ return kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind <= EVENT_KINDS.ADDRESSABLE.MAX;
}
/**
diff --git a/src/lib/utils/event_search.ts b/src/lib/utils/event_search.ts
new file mode 100644
index 0000000..98c0fe0
--- /dev/null
+++ b/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 {
+ // 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 {
+ // 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}`);
+ }
+}
\ No newline at end of file
diff --git a/src/lib/utils/indexEventCache.ts b/src/lib/utils/indexEventCache.ts
new file mode 100644
index 0000000..bd91fd3
--- /dev/null
+++ b/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 = 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
\ No newline at end of file
diff --git a/src/lib/utils/markup/asciidoctorPostProcessor.ts b/src/lib/utils/markup/asciidoctorPostProcessor.ts
index 763c720..8664c02 100644
--- a/src/lib/utils/markup/asciidoctorPostProcessor.ts
+++ b/src/lib/utils/markup/asciidoctorPostProcessor.ts
@@ -120,7 +120,6 @@ export async function postProcessAsciidoctorHtml(
if (!html) return html;
try {
- console.log('HTML before replaceWikilinks:', html);
// First process AsciiDoctor-generated anchors
let processedHtml = replaceAsciiDocAnchors(html);
// Then process wikilinks in [[...]] format (if any remain)
diff --git a/src/lib/utils/mime.ts b/src/lib/utils/mime.ts
index 123b46e..979c294 100644
--- a/src/lib/utils/mime.ts
+++ b/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
* Following NIP specification for kind ranges:
@@ -10,15 +12,16 @@ export function getEventType(
kind: number,
): "regular" | "replaceable" | "ephemeral" | "addressable" {
// Check special ranges first
- if (kind >= 30000 && kind < 40000) {
+ if (kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind < EVENT_KINDS.ADDRESSABLE.MAX) {
return "addressable";
}
- if (kind >= 20000 && kind < 30000) {
+ if (kind >= EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MIN && kind < EVENT_KINDS.PARAMETERIZED_REPLACEABLE.MAX) {
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";
}
diff --git a/src/lib/utils/nostrEventService.ts b/src/lib/utils/nostrEventService.ts
index 89f02a9..8a59d8e 100644
--- a/src/lib/utils/nostrEventService.ts
+++ b/src/lib/utils/nostrEventService.ts
@@ -5,6 +5,7 @@ import { userRelays } from "$lib/stores/relayStore";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import type { NDKEvent } from "./nostrUtils";
+import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants';
export interface RootEventInfo {
rootId: string;
@@ -178,8 +179,8 @@ export function buildReplyTags(
): string[][] {
const tags: string[][] = [];
- const isParentReplaceable = parentInfo.parentKind >= 30000 && parentInfo.parentKind < 40000;
- const isParentComment = parentInfo.parentKind === 1111;
+ const isParentReplaceable = parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN && parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
+ const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT;
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id;
if (kind === 1) {
@@ -199,7 +200,7 @@ export function buildReplyTags(
}
}
} else {
- // Kind 1111 uses NIP-22 threading format
+ // Kind 1111 (comment) uses NIP-22 threading format
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd');
if (dTag) {
@@ -292,7 +293,7 @@ export async function createSignedEvent(
const eventToSign = {
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] || '')]),
content: String(prefixedContent),
pubkey: pubkey,
@@ -329,7 +330,7 @@ async function publishToRelay(relayUrl: string, signedEvent: any): Promise
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timeout"));
- }, 5000);
+ }, TIMEOUTS.GENERAL);
ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", signedEvent]));
diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts
index b30d46b..7b3140c 100644
--- a/src/lib/utils/nostrUtils.ts
+++ b/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 { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/hashes/utils";
+import { wellKnownUrl } from "./search_utility";
+import { TIMEOUTS, VALIDATION } from './search_constants';
const badgeCheckSvg =
'';
@@ -264,19 +266,35 @@ export async function processNostrIdentifiers(
export async function getNpubFromNip05(nip05: string): Promise {
try {
- const ndk = get(ndkInstance);
- if (!ndk) {
- console.error("NDK not initialized");
+ // Parse the NIP-05 address
+ const [name, domain] = nip05.split('@');
+ 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;
}
- const user = await ndk.getUser({ nip05 });
- if (!user || !user.npub) {
+ const data = await response.json();
+
+ const pubkey = data.names?.[name];
+ if (!pubkey) {
+ console.error('[getNpubFromNip05] No pubkey found for name:', name);
return null;
}
- return user.npub;
+
+ // Convert pubkey to npub
+ const npub = nip19.npubEncode(pubkey);
+ return npub;
} catch (error) {
- console.error("Error getting npub from nip05:", error);
+ console.error("[getNpubFromNip05] Error getting npub from nip05:", error);
return null;
}
}
@@ -284,8 +302,8 @@ export async function getNpubFromNip05(nip05: string): Promise {
/**
* Generic utility function to add a timeout to any promise
* Can be used in two ways:
- * 1. Method style: promise.withTimeout(5000)
- * 2. Function style: withTimeout(promise, 5000)
+ * 1. Method style: promise.withTimeout(TIMEOUTS.GENERAL)
+ * 2. Function style: withTimeout(promise, TIMEOUTS.GENERAL)
*
* @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)
@@ -376,7 +394,7 @@ export async function fetchEventWithFallback(
if (
typeof filterOrId === "string" &&
- /^[0-9a-f]{64}$/i.test(filterOrId)
+ new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(filterOrId)
) {
return await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
@@ -446,7 +464,7 @@ export async function fetchEventWithFallback(
export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null;
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);
}
if (pubkey.startsWith("npub1")) return pubkey;
diff --git a/src/lib/utils/profile_search.ts b/src/lib/utils/profile_search.ts
new file mode 100644
index 0000000..29dc408
--- /dev/null
+++ b/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 {
+ 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 | 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((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(),
+ addresses: new Set(),
+ 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 {
+ 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 {
+ 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 = {};
+ 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([]);
+ }
+ });
+ });
+}
\ No newline at end of file
diff --git a/src/lib/utils/relayDiagnostics.ts b/src/lib/utils/relayDiagnostics.ts
index 71a5d99..49a2874 100644
--- a/src/lib/utils/relayDiagnostics.ts
+++ b/src/lib/utils/relayDiagnostics.ts
@@ -1,5 +1,6 @@
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts';
import NDK from '@nostr-dev-kit/ndk';
+import { TIMEOUTS } from './search_constants';
export interface RelayDiagnostic {
url: string;
@@ -31,7 +32,7 @@ export async function testRelay(url: string): Promise {
responseTime: Date.now() - startTime,
});
}
- }, 5000);
+ }, TIMEOUTS.RELAY_DIAGNOSTICS);
ws.onopen = () => {
if (!resolved) {
diff --git a/src/lib/utils/searchCache.ts b/src/lib/utils/searchCache.ts
new file mode 100644
index 0000000..0fabd5b
--- /dev/null
+++ b/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;
+ addresses: Set;
+ searchType: string;
+ searchTerm: string;
+ timestamp: number;
+}
+
+class SearchCache {
+ private cache: Map = 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): 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
\ No newline at end of file
diff --git a/src/lib/utils/search_constants.ts b/src/lib/utils/search_constants.ts
new file mode 100644
index 0000000..2fa927a
--- /dev/null
+++ b/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;
\ No newline at end of file
diff --git a/src/lib/utils/search_types.ts b/src/lib/utils/search_types.ts
new file mode 100644
index 0000000..df75ffc
--- /dev/null
+++ b/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;
+ addresses: Set;
+ searchType: string;
+ searchTerm: string;
+}
+
+/**
+ * Profile search result interface
+ */
+export interface ProfileSearchResult {
+ profiles: NostrProfile[];
+ Status: Record;
+}
+
+/**
+ * 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;
+ addresses?: Set;
+ targetPubkey?: string;
+}
+
+/**
+ * Search callback functions
+ */
+export interface SearchCallbacks {
+ onSecondOrderUpdate?: (result: SearchResult) => void;
+ onSubscriptionCreated?: (sub: any) => void;
+}
\ No newline at end of file
diff --git a/src/lib/utils/search_utility.ts b/src/lib/utils/search_utility.ts
new file mode 100644
index 0000000..a44395a
--- /dev/null
+++ b/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';
\ No newline at end of file
diff --git a/src/lib/utils/search_utils.ts b/src/lib/utils/search_utils.ts
new file mode 100644
index 0000000..5a5d6ac
--- /dev/null
+++ b/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
+ };
+}
\ No newline at end of file
diff --git a/src/lib/utils/subscription_search.ts b/src/lib/utils/subscription_search.ts
new file mode 100644
index 0000000..30f4dda
--- /dev/null
+++ b/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 {
+ 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 | null,
+ firstOrderEvents: [] as NDKEvent[],
+ secondOrderEvents: [] as NDKEvent[],
+ tTagEvents: [] as NDKEvent[],
+ eventIds: new Set(),
+ eventAddresses: new Set(),
+ 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 {
+ 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 {
+ // 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,
+ 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 {
+ 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((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 = {};
+ 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 = {};
+ 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 = new Set(),
+ addresses: Set = 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();
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte
index 410b02d..f49050b 100644
--- a/src/routes/events/+page.svelte
+++ b/src/routes/events/+page.svelte
@@ -11,12 +11,14 @@
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import EventInput from '$lib/components/EventInput.svelte';
- import { userPubkey, isLoggedIn } from '$lib/stores/authStore';
+ import { userPubkey, isLoggedIn } from '$lib/stores/authStore.Svelte';
import { testAllRelays, logRelayDiagnostics } from '$lib/utils/relayDiagnostics';
import CopyToClipboard from '$lib/components/util/CopyToClipboard.svelte';
import { neventEncode, naddrEncode } from '$lib/utils';
import { standardRelays } from '$lib/consts';
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 error = $state(null);
@@ -28,6 +30,8 @@
let tTagResults = $state([]);
let originalEventIds = $state>(new Set());
let originalAddresses = $state>(new Set());
+ let searchType = $state(null);
+ let searchTerm = $state(null);
let profile = $state<{
name?: string;
display_name?: string;
@@ -39,14 +43,25 @@
nip05?: string;
} | null>(null);
let userRelayPreference = $state(false);
+ let showSidePanel = $state(false);
+ let searchInProgress = $state(false);
+ let secondOrderSearchMessage = $state(null);
+ let communityStatus = $state>({});
function handleEventFound(newEvent: NDKEvent) {
event = newEvent;
+ showSidePanel = true;
+ // Clear search results when showing a single event
searchResults = [];
secondOrderResults = [];
tTagResults = [];
originalEventIds = new Set();
originalAddresses = new Set();
+ searchType = null;
+ searchTerm = null;
+ searchInProgress = false;
+ secondOrderSearchMessage = null;
+
if (newEvent.kind === 0) {
try {
profile = JSON.parse(newEvent.content);
@@ -58,20 +73,72 @@
}
}
- function handleSearchResults(results: NDKEvent[], secondOrder: NDKEvent[] = [], tTagEvents: NDKEvent[] = [], eventIds: Set = new Set(), addresses: Set = new Set()) {
+ function handleSearchResults(results: NDKEvent[], secondOrder: NDKEvent[] = [], tTagEvents: NDKEvent[] = [], eventIds: Set = new Set(), addresses: Set = new Set(), searchTypeParam?: string, searchTermParam?: string) {
searchResults = results;
secondOrderResults = secondOrder;
tTagResults = tTagEvents;
originalEventIds = eventIds;
originalAddresses = addresses;
- event = null;
- profile = null;
+ searchType = searchTypeParam || 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() {
+ 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 });
}
+ 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 {
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 {
if (!addr || addr.length <= head + tail + 3) return addr;
return addr.slice(0, head) + '…' + addr.slice(-tail);
@@ -145,21 +223,58 @@
function onLoadingChange(val: boolean) {
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 = {};
+
+ 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 dTag = $page.url.searchParams.get("d");
+ console.log("Events page URL update:", { id, dTag, searchValue });
+
if (id !== searchValue) {
+ console.log("ID changed, updating searchValue:", { old: searchValue, new: id });
searchValue = id;
dTagValue = null;
+ // Only close side panel if we're clearing the search
+ if (!id) {
+ showSidePanel = false;
+ event = null;
+ profile = null;
+ }
}
if (dTag !== dTagValue) {
+ console.log("DTag changed, updating dTagValue:", { old: dTagValue, new: dTag });
// Normalize d-tag to lowercase for consistent searching
dTagValue = dTag ? dTag.toLowerCase() : 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
@@ -167,7 +282,62 @@
event = null;
searchResults = [];
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(() => {
@@ -179,293 +349,408 @@
-
-
- Events
-
-
-
- Use this page to view any event (npub, nprofile, nevent, naddr, note,
- pubkey, or eventID). You can also search for events by d-tag using the
- format "d:tag-name".
-
-
-
-
- {#if event}
- {#if event.kind !== 0}
-
-
- {#if isAddressableEvent(event)}
- {@const naddrAddress = getNaddrAddress(event)}
- {#if naddrAddress}
-
- {/if}
- {/if}
-
- {/if}
-
-
- {#if $isLoggedIn && $userPubkey}
-
- Add Comment
-
-
- {:else}
-
-
Please sign in to add comments.
-
- {/if}
- {/if}
-
- {#if searchResults.length > 0}
-
-
- Search Results for d-tag: "{dTagValue?.toLowerCase()}" ({searchResults.length}
- events)
-
-
- {#each searchResults as result, index}
+
+
+
+
+
+
Events
+ {#if showSidePanel}
- {/each}
+ {/if}
-
- {/if}
- {#if secondOrderResults.length > 0}
-
-
- Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length}
- events)
-
-
- Events that reference, reply to, highlight, or quote the original events.
+
+ Use this page to view any event (npub, nprofile, nevent, naddr, note,
+ pubkey, or eventID). You can also search for events by d-tag using the
+ format "d:tag-name".
-
- {#each secondOrderResults as result, index}
-