clone of repo on github
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.
 
 
 
 

917 lines
28 KiB

<script lang="ts">
import { onMount } from 'svelte';
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 { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import type { SearchResult } from '$lib/utils/search_types';
import { userStore } from "$lib/stores/userStore";
import { get } from "svelte/store";
// 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,
) => void;
event: NDKEvent | null;
onClear?: () => void;
onLoadingChange?: (loading: boolean) => void;
} = $props();
// Component state
let searchQuery = $state("");
let localError = $state<string | null>(null);
let foundEvent = $state<NDKEvent | null>(null);
let searching = $state(false);
let searchCompleted = $state(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);
// Move search handler functions above all $effect runes
async function handleNip05Search(query: string) {
try {
const foundEvent = await searchNip05(query);
if (foundEvent) {
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, "nip05");
} else {
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, 0, "nip05");
}
} catch (error) {
localError =
error instanceof Error ? error.message : "NIP-05 lookup failed";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
lastSearchValue = null;
}
}
async function handleEventSearch(query: string) {
try {
const foundEvent = await searchEvent(query);
if (!foundEvent) {
console.warn("[Events] Event not found for query:", query);
localError = "Event not found";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
} else {
console.log("[Events] Event found:", foundEvent);
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, "event");
}
} catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again.";
// relayStatuses = {}; // This line was removed as per the edit hint
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
}
}
async function handleSearchEvent(
clearInput: boolean = true,
queryOverride?: string,
) {
if (searching) {
console.log("EventSearch: Already searching, skipping");
return;
}
resetSearchState();
localError = null;
updateSearchState(true);
isResetting = false;
isUserEditing = false; // Reset user editing flag when search starts
const query = (
queryOverride !== undefined ? queryOverride || "" : searchQuery || ""
).trim();
if (!query) {
updateSearchState(false, false, null, null);
return;
}
if (query.toLowerCase().startsWith("d:")) {
const dTag = query.slice(2).trim().toLowerCase();
if (dTag) {
console.log("EventSearch: Processing d-tag search:", dTag);
navigateToSearch(dTag, "d");
updateSearchState(false, false, null, null);
return;
}
}
if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription("t", searchTerm);
return;
}
}
if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription("n", searchTerm);
return;
}
}
if (query.includes("@")) {
await handleNip05Search(query);
return;
}
if (clearInput) {
navigateToSearch(query, "id");
// Don't clear searchQuery here - let the effect handle it
}
await handleEventSearch(query);
}
// Keep searchQuery in sync with searchValue and dTagValue props
$effect(() => {
// Only sync if we're not currently searching, resetting, or if the user is editing
if (searching || isResetting || isUserEditing) {
return;
}
if (dTagValue) {
// If dTagValue is set, show it as "d:tag" in the search bar
searchQuery = `d:${dTagValue}`;
} else if (searchValue) {
// searchValue should already be in the correct format (t:, n:, d:, etc.)
searchQuery = searchValue;
} else if (!searchQuery) {
// Only clear if searchQuery is empty to avoid clearing user input
searchQuery = "";
}
});
// Debounced effect to handle searchValue changes
$effect(() => {
if (
!searchValue ||
searching ||
isResetting ||
isProcessingSearch ||
isWaitingForSearchResult
) {
return;
}
// Check if we've already processed this searchValue
if (searchValue === lastProcessedSearchValue) {
return;
}
// If we already have the event for this searchValue, do nothing
if (foundEvent) {
const currentEventId = foundEvent.id;
let currentNaddr = null;
let currentNevent = null;
let currentNpub = null;
try {
currentNevent = neventEncode(foundEvent, $activeInboxRelays);
} catch {}
try {
currentNaddr = getMatchingTags(foundEvent, "d")[0]?.[1]
? naddrEncode(foundEvent, $activeInboxRelays)
: null;
} catch {}
try {
currentNpub = foundEvent.kind === 0 ? toNpub(foundEvent.pubkey) : null;
} catch {}
// Debug log for comparison
console.log(
"[EventSearch effect] searchValue:",
searchValue,
"foundEvent.id:",
currentEventId,
"foundEvent.pubkey:",
foundEvent.pubkey,
"toNpub(pubkey):",
currentNpub,
"foundEvent.kind:",
foundEvent.kind,
"currentNaddr:",
currentNaddr,
"currentNevent:",
currentNevent,
);
// Also check if searchValue is an nprofile and matches the current event's pubkey
let currentNprofile = null;
if (
searchValue &&
searchValue.startsWith("nprofile1") &&
foundEvent.kind === 0
) {
try {
currentNprofile = nprofileEncode(foundEvent.pubkey, $activeInboxRelays);
} catch {}
}
if (
searchValue === currentEventId ||
(currentNaddr && searchValue === currentNaddr) ||
(currentNevent && searchValue === currentNevent) ||
(currentNpub && searchValue === currentNpub) ||
(currentNprofile && searchValue === currentNprofile)
) {
// Already displaying the event for this searchValue
lastProcessedSearchValue = searchValue;
return;
}
}
// Otherwise, trigger a search for the new value
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
isProcessingSearch = true;
isWaitingForSearchResult = true;
lastProcessedSearchValue = searchValue;
if (searchValue) {
handleSearchEvent(false, searchValue);
}
}, 300);
});
// Add debouncing to prevent rapid successive searches
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Cleanup function to clear timeout when component is destroyed
$effect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
});
// Simple effect to handle dTagValue changes
$effect(() => {
if (
dTagValue &&
!searching &&
!isResetting &&
dTagValue !== lastProcessedDTagValue
) {
console.log("EventSearch: Processing dTagValue:", dTagValue);
lastProcessedDTagValue = dTagValue;
// Add a small delay to prevent rapid successive calls
setTimeout(() => {
if (!searching && !isResetting) {
handleSearchBySubscription("d", dTagValue);
}
}, 100);
}
});
// Simple effect to handle event prop changes
$effect(() => {
if (event && !searching && !isResetting) {
foundEvent = event;
}
});
// Search utility functions
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);
// Cancel ongoing search
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
// Clean up subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
// Clear search results
onSearchResults([], [], [], new Set(), new Set());
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
// Reset the flag after a short delay to allow effects to settle
setTimeout(() => {
isResetting = false;
}, 100);
}
function handleFoundEvent(event: NDKEvent) {
foundEvent = event;
localError = null; // Clear local error when event is found
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
// Clear search state
searching = false;
searchCompleted = true;
searchResultCount = 1;
searchResultType = "event";
// Update last processed search value to prevent re-processing
if (searchValue) {
lastProcessedSearchValue = searchValue;
lastSearchValue = searchValue;
}
// Reset processing flag
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,
});
}
// Search handlers
async function handleSearchBySubscription(
searchType: "d" | "t" | "n",
searchTerm: string,
) {
console.log("EventSearch: Starting subscription search:", {
searchType,
searchTerm,
});
// AI-NOTE: 2025-01-24 - Check cache first for profile searches to provide immediate response
if (searchType === "n") {
try {
const { getUserMetadata } = await import("$lib/utils/nostrUtils");
const cachedProfile = await getUserMetadata(searchTerm, false);
if (cachedProfile && cachedProfile.name) {
console.log("EventSearch: Found cached profile, displaying immediately:", cachedProfile);
// Create a mock NDKEvent for the cached profile
const { NDKEvent } = await import("@nostr-dev-kit/ndk");
const { nip19 } = await import("$lib/utils/nostrUtils");
// Decode the npub to get the actual pubkey
let pubkey = searchTerm;
try {
const decoded = nip19.decode(searchTerm);
if (decoded && decoded.type === "npub") {
pubkey = decoded.data;
}
} catch (error) {
console.warn("EventSearch: Failed to decode npub for mock event:", error);
}
const mockEvent = new NDKEvent(undefined, {
kind: 0,
pubkey: pubkey,
content: JSON.stringify(cachedProfile),
tags: [],
created_at: Math.floor(Date.now() / 1000),
id: "", // Will be computed by NDK
sig: "", // Will be computed by NDK
});
// Display the cached profile immediately
handleFoundEvent(mockEvent);
updateSearchState(false, true, 1, "profile-cached");
// AI-NOTE: 2025-01-24 - Still perform background search for second-order events
// but with better timeout handling to prevent hanging
setTimeout(async () => {
try {
await performBackgroundProfileSearch(searchType, searchTerm);
} catch (error) {
console.warn("EventSearch: Background profile search failed:", error);
}
}, 100);
return;
}
} catch (error) {
console.warn("EventSearch: Cache check failed, proceeding with subscription search:", error);
}
}
isResetting = false; // Allow effects to run for new searches
localError = null;
updateSearchState(true);
// Wait for relays to be available (with timeout)
let retryCount = 0;
const maxRetries = 20; // Wait up to 10 seconds (20 * 500ms) for user login to complete
while ($activeInboxRelays.length === 0 && $activeOutboxRelays.length === 0 && retryCount < maxRetries) {
console.debug(`EventSearch: Waiting for relays... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, 500)); // Wait 500ms
retryCount++;
}
// Additional wait for user-specific relays if user is logged in
const currentUser = get(userStore);
if (currentUser.signedIn && currentUser.pubkey) {
console.debug(`EventSearch: User is logged in (${currentUser.pubkey}), waiting for user-specific relays...`);
retryCount = 0;
while ($activeOutboxRelays.length <= 9 && retryCount < maxRetries) {
// If we still have the default relay count (9), wait for user-specific relays
console.debug(`EventSearch: Waiting for user-specific relays... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, 500));
retryCount++;
}
}
// Check if we have any relays available
if ($activeInboxRelays.length === 0 && $activeOutboxRelays.length === 0) {
console.warn("EventSearch: No relays available after waiting, failing search");
localError = "No relays available. Please check your connection and try again.";
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
searching = false;
return;
}
console.log("EventSearch: Relays available, proceeding with search:", {
inboxCount: $activeInboxRelays.length,
outboxCount: $activeOutboxRelays.length
});
try {
// Cancel existing search
if (currentAbortController) {
currentAbortController.abort();
}
currentAbortController = new AbortController();
// Add a timeout to prevent hanging searches
const searchPromise = searchBySubscription(
searchType,
searchTerm,
{
onSecondOrderUpdate: (updatedResult) => {
console.log("EventSearch: Second order update:", updatedResult);
onSearchResults(
updatedResult.events,
updatedResult.secondOrder,
updatedResult.tTagEvents,
updatedResult.eventIds,
updatedResult.addresses,
updatedResult.searchType,
updatedResult.searchTerm,
);
},
onSubscriptionCreated: (sub) => {
console.log("EventSearch: Subscription created:", sub);
if (activeSub) {
activeSub.stop();
}
activeSub = sub;
},
},
currentAbortController.signal,
);
// Add a 30-second timeout
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,
result.searchTerm,
);
const totalCount =
result.events.length +
result.secondOrder.length +
result.tTagEvents.length;
localError = null; // Clear local error when search completes
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, totalCount, searchType);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
// Update last processed search value to prevent re-processing
if (searchValue) {
lastProcessedSearchValue = searchValue;
}
} catch (error) {
if (error instanceof Error && error.message === "Search cancelled") {
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
return;
}
console.error("EventSearch: Search failed:", error);
localError = error instanceof Error ? error.message : "Search failed";
// Provide more specific error messages for different failure types
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}`;
}
}
localError = null; // Clear local error when search fails
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn("Error stopping subscription:", e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
// Update last processed search value to prevent re-processing even on error
if (searchValue) {
lastProcessedSearchValue = searchValue;
}
}
}
// AI-NOTE: 2025-01-24 - Function to perform background profile search without blocking UI
async function performBackgroundProfileSearch(
searchType: "d" | "t" | "n",
searchTerm: string,
) {
console.log("EventSearch: Performing background profile search:", {
searchType,
searchTerm,
});
try {
// Cancel existing search
if (currentAbortController) {
currentAbortController.abort();
}
currentAbortController = new AbortController();
// AI-NOTE: 2025-01-24 - Add timeout to prevent hanging background searches
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Background search timeout"));
}, 10000); // 10 second timeout for background searches
});
const searchPromise = searchBySubscription(
searchType,
searchTerm,
{
onSecondOrderUpdate: (updatedResult) => {
console.log("EventSearch: Background second order update:", updatedResult);
// Only update if we have new results
if (updatedResult.events.length > 0) {
onSearchResults(
updatedResult.events,
updatedResult.secondOrder,
updatedResult.tTagEvents,
updatedResult.eventIds,
updatedResult.addresses,
updatedResult.searchType,
updatedResult.searchTerm,
);
}
},
onSubscriptionCreated: (sub) => {
console.log("EventSearch: Background subscription created:", sub);
if (activeSub) {
activeSub.stop();
}
activeSub = sub;
},
},
currentAbortController.signal,
);
// Race between search and timeout
const result = await Promise.race([searchPromise, timeoutPromise]) as any;
console.log("EventSearch: Background search completed:", result);
// Only update results if we have new data
if (result.events.length > 0) {
onSearchResults(
result.events,
result.secondOrder,
result.tTagEvents,
result.eventIds,
result.addresses,
result.searchType,
result.searchTerm,
);
}
} catch (error) {
console.warn("EventSearch: Background profile search failed:", error);
}
}
// Search utility functions
function handleClear() {
isResetting = true;
searchQuery = "";
isUserEditing = false; // Reset user editing flag
resetSearchState();
// Clear URL parameters to reset the page
goto("", {
replaceState: true,
keepFocus: true,
noScroll: true,
});
// Ensure all search state is cleared
searching = false;
searchCompleted = false;
searchResultCount = null;
searchResultType = null;
foundEvent = null;
localError = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
isWaitingForSearchResult = false;
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
if (onClear) {
onClear();
}
// Reset the flag after a short delay to allow effects to settle
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>