You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
721 lines
21 KiB
721 lines
21 KiB
<script lang="ts"> |
|
import { goto } from '$app/navigation'; |
|
import { Input, Button } from "flowbite-svelte"; |
|
import { Spinner } from "flowbite-svelte"; |
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
|
import { |
|
searchEvent, |
|
searchBySubscription, |
|
searchNip05, |
|
} from "$lib/utils/search_utility"; |
|
import type { SearchCallbacks } from "$lib/utils/search_types"; |
|
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; |
|
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk"; |
|
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils"; |
|
import type NDK from '@nostr-dev-kit/ndk'; |
|
|
|
// Props definition |
|
let { |
|
loading, |
|
error, |
|
searchValue, |
|
dTagValue, |
|
onEventFound, |
|
onSearchResults, |
|
event, |
|
onClear, |
|
onLoadingChange, |
|
}: { |
|
loading: boolean; |
|
error: string | null; |
|
searchValue: string | null; |
|
dTagValue: string | null; |
|
onEventFound: (event: NDKEvent) => void; |
|
onSearchResults: ( |
|
firstOrder: NDKEvent[], |
|
secondOrder: NDKEvent[], |
|
tTagEvents: NDKEvent[], |
|
eventIds: Set<string>, |
|
addresses: Set<string>, |
|
searchType?: string, |
|
searchTerm?: string, |
|
loading?: boolean, // AI-NOTE: 2025-01-24 - Add loading parameter for second-order search message logic |
|
) => void; |
|
event: NDKEvent | null; |
|
onClear?: () => void; |
|
onLoadingChange?: (loading: boolean) => void; |
|
} = $props(); |
|
|
|
const ndk = getNdkContext(); |
|
|
|
// Component state |
|
let searchQuery = $state(""); |
|
let localError = $state<string | null>(null); |
|
let foundEvent = $state<NDKEvent | null>(null); |
|
let searching = $state(false); |
|
let searchCompleted = $state<boolean>(false); |
|
let searchResultCount = $state<number | null>(null); |
|
let searchResultType = $state<string | null>(null); |
|
let isResetting = $state(false); |
|
|
|
// Internal state for cleanup |
|
let activeSub: any = null; |
|
let currentAbortController: AbortController | null = null; |
|
|
|
// Derived values |
|
let hasActiveSearch = $derived(searching && !foundEvent); |
|
let showError = $derived(localError || error); |
|
let showSuccess = $derived(searchCompleted && searchResultCount !== null); |
|
|
|
// Track last processed values to prevent loops |
|
let lastProcessedSearchValue = $state<string | null>(null); |
|
let lastProcessedDTagValue = $state<string | null>(null); |
|
let isProcessingSearch = $state(false); |
|
let currentProcessingSearchValue = $state<string | null>(null); |
|
let lastSearchValue = $state<string | null>(null); |
|
let isWaitingForSearchResult = $state(false); |
|
let isUserEditing = $state(false); |
|
|
|
// Debounced search timeout |
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null; |
|
|
|
// AI-NOTE: 2025-01-24 - Core search handlers extracted for better organization |
|
async function handleNip05Search(query: string) { |
|
try { |
|
const foundEvent = await searchNip05(query, ndk); |
|
if (foundEvent) { |
|
handleFoundEvent(foundEvent); |
|
updateSearchState(false, true, 1, "nip05"); |
|
} else { |
|
cleanupSearch(); |
|
updateSearchState(false, true, 0, "nip05"); |
|
} |
|
} catch (error) { |
|
handleSearchError(error, "NIP-05 lookup failed"); |
|
} |
|
} |
|
|
|
async function handleEventSearch(query: string) { |
|
try { |
|
const foundEvent = await searchEvent(query, ndk); |
|
if (!foundEvent) { |
|
console.warn("[Events] Event not found for query:", query); |
|
localError = "Event not found"; |
|
cleanupSearch(); |
|
updateSearchState(false, false, null, null); |
|
} else { |
|
console.log("[Events] Event found:", foundEvent); |
|
handleFoundEvent(foundEvent); |
|
updateSearchState(false, true, 1, "event"); |
|
} |
|
} catch (err) { |
|
handleSearchError(err, "Error fetching event. Please check the ID and try again."); |
|
} |
|
} |
|
|
|
async function handleSearchEvent( |
|
clearInput: boolean = true, |
|
queryOverride?: string, |
|
) { |
|
if (searching) { |
|
console.log("EventSearch: Already searching, skipping"); |
|
return; |
|
} |
|
|
|
resetSearchState(); |
|
localError = null; |
|
updateSearchState(true, false); |
|
isResetting = false; |
|
isUserEditing = false; |
|
|
|
const query = (queryOverride !== undefined ? queryOverride || "" : searchQuery || "").trim(); |
|
if (!query) { |
|
updateSearchState(false, false, null, null); |
|
return; |
|
} |
|
|
|
// Handle different search types |
|
const searchType = getSearchType(query); |
|
if (searchType) { |
|
await handleSearchByType(searchType, query, clearInput); |
|
return; |
|
} |
|
|
|
// AI-NOTE: 2025-01-24 - If no specific search type is detected, treat as event ID search |
|
if (clearInput) { |
|
navigateToSearch(query, "id"); |
|
} |
|
await handleEventSearch(query); |
|
} |
|
|
|
// AI-NOTE: 2025-01-24 - Helper functions for better code organization |
|
function getSearchType(query: string): { type: string; term: string } | null { |
|
const lowerQuery = query.toLowerCase(); |
|
|
|
if (lowerQuery.startsWith("d:")) { |
|
const dTag = query.slice(2).trim().toLowerCase(); |
|
return dTag ? { type: "d", term: dTag } : null; |
|
} |
|
|
|
if (lowerQuery.startsWith("t:")) { |
|
const searchTerm = query.slice(2).trim(); |
|
return searchTerm ? { type: "t", term: searchTerm } : null; |
|
} |
|
|
|
if (lowerQuery.startsWith("n:")) { |
|
const searchTerm = query.slice(2).trim(); |
|
return searchTerm ? { type: "n", term: searchTerm } : null; |
|
} |
|
|
|
if (query.includes("@")) { |
|
return { type: "nip05", term: query }; |
|
} |
|
|
|
// AI-NOTE: 2025-01-24 - Treat plain text searches as profile searches by default |
|
// This allows searching for names like "thebeave" or "TheBeave" without needing n: prefix |
|
const trimmedQuery = query.trim(); |
|
if (trimmedQuery && !trimmedQuery.startsWith("nevent") && !trimmedQuery.startsWith("npub") && !trimmedQuery.startsWith("naddr")) { |
|
return { type: "n", term: trimmedQuery }; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
async function handleSearchByType( |
|
searchType: { type: string; term: string }, |
|
query: string, |
|
clearInput: boolean |
|
) { |
|
const { type, term } = searchType; |
|
|
|
if (type === "d") { |
|
console.log("EventSearch: Processing d-tag search:", term); |
|
navigateToSearch(term, "d"); |
|
updateSearchState(false, false, null, null); |
|
return; |
|
} |
|
|
|
if (type === "nip05") { |
|
await handleNip05Search(term); |
|
return; |
|
} |
|
|
|
if (type === "t" || type === "n") { |
|
await handleSearchBySubscription(type as "t" | "n", term); |
|
return; |
|
} |
|
} |
|
|
|
function handleSearchError(error: unknown, defaultMessage: string) { |
|
localError = error instanceof Error ? error.message : defaultMessage; |
|
cleanupSearch(); |
|
updateSearchState(false, false, null, null); |
|
isProcessingSearch = false; |
|
currentProcessingSearchValue = null; |
|
lastSearchValue = null; |
|
} |
|
|
|
function cleanupSearch() { |
|
if (activeSub) { |
|
try { |
|
activeSub.stop(); |
|
} catch (e) { |
|
console.warn("Error stopping subscription:", e); |
|
} |
|
activeSub = null; |
|
} |
|
|
|
if (currentAbortController) { |
|
currentAbortController.abort(); |
|
currentAbortController = null; |
|
} |
|
} |
|
|
|
// AI-NOTE: 2025-01-24 - Effects organized for better readability |
|
$effect(() => { |
|
if (searching || isResetting || isUserEditing) { |
|
return; |
|
} |
|
|
|
if (dTagValue) { |
|
searchQuery = `d:${dTagValue}`; |
|
} else if (searchValue) { |
|
searchQuery = searchValue; |
|
} else if (!searchQuery) { |
|
searchQuery = ""; |
|
} |
|
}); |
|
|
|
$effect(() => { |
|
if ( |
|
!searchValue || |
|
searching || |
|
isResetting || |
|
isProcessingSearch || |
|
isWaitingForSearchResult |
|
) { |
|
return; |
|
} |
|
|
|
if (searchValue === lastProcessedSearchValue) { |
|
return; |
|
} |
|
|
|
if (foundEvent && isCurrentEventMatch(searchValue, foundEvent)) { |
|
lastProcessedSearchValue = searchValue; |
|
return; |
|
} |
|
|
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
} |
|
|
|
searchTimeout = setTimeout(() => { |
|
isProcessingSearch = true; |
|
isWaitingForSearchResult = true; |
|
lastProcessedSearchValue = searchValue; |
|
if (searchValue) { |
|
handleSearchEvent(false, searchValue); |
|
} |
|
}, 300); |
|
}); |
|
|
|
$effect(() => { |
|
return () => { |
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
} |
|
}; |
|
}); |
|
|
|
$effect(() => { |
|
if ( |
|
dTagValue && |
|
!searching && |
|
!isResetting && |
|
dTagValue !== lastProcessedDTagValue |
|
) { |
|
console.log("EventSearch: Processing dTagValue:", dTagValue); |
|
lastProcessedDTagValue = dTagValue; |
|
|
|
setTimeout(() => { |
|
if (!searching && !isResetting) { |
|
handleSearchBySubscription("d", dTagValue); |
|
} |
|
}, 100); |
|
} |
|
}); |
|
|
|
$effect(() => { |
|
if (event && !searching && !isResetting) { |
|
foundEvent = event; |
|
} |
|
}); |
|
|
|
// AI-NOTE: 2025-01-24 - Utility functions for event matching and state management |
|
function isCurrentEventMatch(searchValue: string, event: NDKEvent): boolean { |
|
const currentEventId = event.id; |
|
let currentNaddr: string | null = null; |
|
let currentNevent: string | null = null; |
|
let currentNpub: string | null = null; |
|
let currentNprofile: string | null = null; |
|
|
|
try { |
|
currentNevent = neventEncode(event, $activeInboxRelays); |
|
} catch {} |
|
|
|
try { |
|
currentNaddr = getMatchingTags(event, "d")[0]?.[1] |
|
? naddrEncode(event, $activeInboxRelays) |
|
: null; |
|
} catch {} |
|
|
|
try { |
|
currentNpub = event.kind === 0 ? toNpub(event.pubkey) : null; |
|
} catch {} |
|
|
|
if ( |
|
searchValue && |
|
searchValue.startsWith("nprofile1") && |
|
event.kind === 0 |
|
) { |
|
try { |
|
currentNprofile = nprofileEncode(event.pubkey, $activeInboxRelays); |
|
} catch {} |
|
} |
|
|
|
return !!( |
|
searchValue === currentEventId || |
|
(currentNaddr && searchValue === currentNaddr) || |
|
(currentNevent && searchValue === currentNevent) || |
|
(currentNpub && searchValue === currentNpub) || |
|
(currentNprofile && searchValue === currentNprofile) |
|
); |
|
} |
|
|
|
function updateSearchState( |
|
isSearching: boolean, |
|
completed: boolean = false, |
|
count: number | null = null, |
|
type: string | null = null, |
|
) { |
|
searching = isSearching; |
|
searchCompleted = completed; |
|
searchResultCount = count; |
|
searchResultType = type; |
|
if (onLoadingChange) { |
|
onLoadingChange(isSearching); |
|
} |
|
} |
|
|
|
function resetSearchState() { |
|
isResetting = true; |
|
foundEvent = null; |
|
localError = null; |
|
lastProcessedSearchValue = null; |
|
lastProcessedDTagValue = null; |
|
isProcessingSearch = false; |
|
currentProcessingSearchValue = null; |
|
lastSearchValue = null; |
|
updateSearchState(false, false, null, null); |
|
|
|
cleanupSearch(); |
|
onSearchResults([], [], [], new Set(), new Set()); |
|
|
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
searchTimeout = null; |
|
} |
|
|
|
setTimeout(() => { |
|
isResetting = false; |
|
}, 100); |
|
} |
|
|
|
function handleFoundEvent(event: NDKEvent) { |
|
foundEvent = event; |
|
localError = null; |
|
|
|
cleanupSearch(); |
|
|
|
searching = false; |
|
searchCompleted = true; |
|
searchResultCount = 1; |
|
searchResultType = "event"; |
|
|
|
if (searchValue) { |
|
lastProcessedSearchValue = searchValue; |
|
lastSearchValue = searchValue; |
|
} |
|
|
|
isProcessingSearch = false; |
|
currentProcessingSearchValue = null; |
|
isWaitingForSearchResult = false; |
|
|
|
onEventFound(event); |
|
} |
|
|
|
function navigateToSearch(query: string, paramName: string) { |
|
const encoded = encodeURIComponent(query); |
|
goto(`?${paramName}=${encoded}`, { |
|
replaceState: false, |
|
keepFocus: true, |
|
noScroll: true, |
|
}); |
|
} |
|
|
|
// AI-NOTE: 2025-01-24 - Main subscription search handler with improved error handling |
|
async function handleSearchBySubscription( |
|
searchType: "d" | "t" | "n", |
|
searchTerm: string, |
|
) { |
|
console.log("EventSearch: Starting subscription search:", { |
|
searchType, |
|
searchTerm, |
|
}); |
|
|
|
// AI-NOTE: 2025-01-24 - Profile search caching is now handled by centralized searchProfiles function |
|
// No need for separate caching logic here as it's handled in profile_search.ts |
|
|
|
isResetting = false; |
|
localError = null; |
|
updateSearchState(true, false); |
|
|
|
await waitForRelays(); |
|
|
|
try { |
|
await performSubscriptionSearch(searchType, searchTerm); |
|
} catch (error) { |
|
handleSubscriptionSearchError(error); |
|
} |
|
} |
|
|
|
// AI-NOTE: 2025-01-24 - Profile search is now handled by centralized searchProfiles function |
|
// These functions are no longer needed as profile searches go through subscription_search.ts |
|
// which delegates to the centralized profile_search.ts |
|
|
|
async function waitForRelays(): Promise<void> { |
|
let retryCount = 0; |
|
const maxRetries = 10; // Reduced retry count since we'll use all available relays |
|
|
|
// AI-NOTE: 2025-01-24 - Wait for any relays to be available, not just specific types |
|
// This ensures searches can proceed even if some relay types are not available |
|
while (retryCount < maxRetries) { |
|
// Check if we have any relays in the NDK pool |
|
if (ndk && ndk.pool && ndk.pool.relays && ndk.pool.relays.size > 0) { |
|
console.debug(`EventSearch: Found ${ndk.pool.relays.size} relays in NDK pool`); |
|
break; |
|
} |
|
|
|
// Also check active relay stores as fallback |
|
if ($activeInboxRelays.length > 0 || $activeOutboxRelays.length > 0) { |
|
console.debug(`EventSearch: Found active relays - inbox: ${$activeInboxRelays.length}, outbox: ${$activeOutboxRelays.length}`); |
|
break; |
|
} |
|
|
|
console.debug(`EventSearch: Waiting for relays... (attempt ${retryCount + 1}/${maxRetries})`); |
|
await new Promise(resolve => setTimeout(resolve, 500)); |
|
retryCount++; |
|
} |
|
|
|
// AI-NOTE: 2025-01-24 - Don't fail if no relays are available, let the search functions handle fallbacks |
|
// The search functions will use all available relays including fallback relays |
|
const poolRelayCount = ndk?.pool?.relays?.size || 0; |
|
|
|
console.log("EventSearch: Relay status for search:", { |
|
poolRelayCount, |
|
inboxCount: $activeInboxRelays.length, |
|
outboxCount: $activeOutboxRelays.length, |
|
willUseAllRelays: poolRelayCount > 0 || $activeInboxRelays.length > 0 || $activeOutboxRelays.length > 0 |
|
}); |
|
|
|
// If we have any relays available, proceed with search |
|
if (poolRelayCount > 0 || $activeInboxRelays.length > 0 || $activeOutboxRelays.length > 0) { |
|
console.log("EventSearch: Relays available, proceeding with search"); |
|
} else { |
|
console.warn("EventSearch: No relays detected, but proceeding with search - fallback relays will be used"); |
|
} |
|
} |
|
|
|
async function performSubscriptionSearch(searchType: "d" | "t" | "n", searchTerm: string): Promise<void> { |
|
if (currentAbortController) { |
|
currentAbortController.abort(); |
|
} |
|
currentAbortController = new AbortController(); |
|
|
|
const searchPromise = searchBySubscription( |
|
searchType, |
|
searchTerm, |
|
ndk, |
|
{ |
|
onSecondOrderUpdate: (updatedResult) => { |
|
console.log("EventSearch: Second order update:", updatedResult); |
|
onSearchResults( |
|
updatedResult.events, |
|
updatedResult.secondOrder, |
|
updatedResult.tTagEvents, |
|
updatedResult.eventIds, |
|
updatedResult.addresses, |
|
updatedResult.searchType, |
|
searchValue || updatedResult.searchTerm, // AI-NOTE: 2025-01-24 - Use original search value for display |
|
false, // AI-NOTE: 2025-01-24 - Second-order update means search is complete |
|
); |
|
}, |
|
onSubscriptionCreated: (sub) => { |
|
console.log("EventSearch: Subscription created:", sub); |
|
if (activeSub) { |
|
activeSub.stop(); |
|
} |
|
activeSub = sub; |
|
}, |
|
}, |
|
currentAbortController.signal, |
|
); |
|
|
|
const timeoutPromise = new Promise((_, reject) => { |
|
setTimeout(() => { |
|
reject(new Error("Search timeout: No results received within 30 seconds")); |
|
}, 30000); |
|
}); |
|
|
|
const result = await Promise.race([searchPromise, timeoutPromise]) as any; |
|
console.log("EventSearch: Search completed:", result); |
|
|
|
onSearchResults( |
|
result.events, |
|
result.secondOrder, |
|
result.tTagEvents, |
|
result.eventIds, |
|
result.addresses, |
|
result.searchType, |
|
searchValue || result.searchTerm, // AI-NOTE: 2025-01-24 - Use original search value for display |
|
false, // AI-NOTE: 2025-01-24 - Search is complete |
|
); |
|
|
|
const totalCount = result.events.length + result.secondOrder.length + result.tTagEvents.length; |
|
localError = null; |
|
|
|
cleanupSearch(); |
|
updateSearchState(false, true, totalCount, searchType); |
|
isProcessingSearch = false; |
|
currentProcessingSearchValue = null; |
|
isWaitingForSearchResult = false; |
|
|
|
if (searchValue) { |
|
lastProcessedSearchValue = searchValue; |
|
} |
|
} |
|
|
|
function handleSubscriptionSearchError(error: unknown): void { |
|
if (error instanceof Error && error.message === "Search cancelled") { |
|
isProcessingSearch = false; |
|
currentProcessingSearchValue = null; |
|
isWaitingForSearchResult = false; |
|
return; |
|
} |
|
|
|
console.error("EventSearch: Search failed:", error); |
|
|
|
if (error instanceof Error) { |
|
if (error.message.includes("timeout") || error.message.includes("connection")) { |
|
localError = "Search timed out. The relays may be temporarily unavailable. Please try again."; |
|
} else if (error.message.includes("NDK not initialized")) { |
|
localError = "Nostr client not initialized. Please refresh the page and try again."; |
|
} else { |
|
localError = `Search failed: ${error.message}`; |
|
} |
|
} else { |
|
localError = "Search failed"; |
|
} |
|
|
|
cleanupSearch(); |
|
updateSearchState(false, false, null, null); |
|
isProcessingSearch = false; |
|
currentProcessingSearchValue = null; |
|
isWaitingForSearchResult = false; |
|
|
|
if (searchValue) { |
|
lastProcessedSearchValue = searchValue; |
|
} |
|
} |
|
|
|
// AI-NOTE: 2025-01-24 - Background profile search is now handled by centralized searchProfiles function |
|
// This function is no longer needed as profile searches go through subscription_search.ts |
|
// which delegates to the centralized profile_search.ts |
|
|
|
function handleClear() { |
|
isResetting = true; |
|
searchQuery = ""; |
|
isUserEditing = false; |
|
resetSearchState(); |
|
|
|
goto("", { |
|
replaceState: true, |
|
keepFocus: true, |
|
noScroll: true, |
|
}); |
|
|
|
searching = false; |
|
searchCompleted = false; |
|
searchResultCount = null; |
|
searchResultType = null; |
|
foundEvent = null; |
|
localError = null; |
|
isProcessingSearch = false; |
|
currentProcessingSearchValue = null; |
|
lastSearchValue = null; |
|
isWaitingForSearchResult = false; |
|
|
|
if (searchTimeout) { |
|
clearTimeout(searchTimeout); |
|
searchTimeout = null; |
|
} |
|
|
|
if (onClear) { |
|
onClear(); |
|
} |
|
|
|
setTimeout(() => { |
|
isResetting = false; |
|
}, 100); |
|
} |
|
|
|
function getResultMessage(): string { |
|
if (searchResultCount === 0) { |
|
return "Search completed. No results found."; |
|
} |
|
|
|
const typeLabel = |
|
searchResultType === "n" |
|
? "profile" |
|
: searchResultType === "nip05" |
|
? "NIP-05 address" |
|
: "event"; |
|
const countLabel = searchResultType === "n" ? "profiles" : "events"; |
|
|
|
return searchResultCount === 1 |
|
? `Search completed. Found 1 ${typeLabel}.` |
|
: `Search completed. Found ${searchResultCount} ${countLabel}.`; |
|
} |
|
|
|
function getNeventUrl(event: NDKEvent): string { |
|
return neventEncode(event, $activeInboxRelays); |
|
} |
|
|
|
function getNaddrUrl(event: NDKEvent): string { |
|
return naddrEncode(event, $activeInboxRelays); |
|
} |
|
|
|
function getNprofileUrl(pubkey: string): string { |
|
return nprofileEncode(pubkey, $activeInboxRelays); |
|
} |
|
</script> |
|
|
|
<div class="flex flex-col space-y-6"> |
|
<!-- Search Input Section --> |
|
<div class="flex gap-2 items-center"> |
|
<Input |
|
bind:value={searchQuery} |
|
placeholder="Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username..." |
|
class="flex-grow" |
|
onkeydown={(e: KeyboardEvent) => |
|
e.key === "Enter" && handleSearchEvent(true)} |
|
oninput={() => (isUserEditing = true)} |
|
onblur={() => (isUserEditing = false)} |
|
/> |
|
<Button onclick={() => handleSearchEvent(true)} disabled={loading}> |
|
{#if searching} |
|
<Spinner class="mr-2 text-gray-600 dark:text-gray-300" size="5" /> |
|
{/if} |
|
{searching ? "Searching..." : "Search"} |
|
</Button> |
|
<Button |
|
onclick={handleClear} |
|
color="alternative" |
|
type="button" |
|
disabled={loading} |
|
> |
|
Clear |
|
</Button> |
|
</div> |
|
|
|
<!-- Error Display --> |
|
{#if showError} |
|
<div |
|
class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" |
|
role="alert" |
|
> |
|
{localError || error} |
|
</div> |
|
{/if} |
|
|
|
<!-- Success Display --> |
|
{#if showSuccess} |
|
<div |
|
class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg" |
|
role="alert" |
|
> |
|
{getResultMessage()} |
|
</div> |
|
{/if} |
|
</div>
|
|
|