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.
 
 
 
 

923 lines
27 KiB

<script lang="ts">
import { goto } from "$app/navigation";
import { Input, Button } from "flowbite-svelte";
import { Spinner } from "flowbite-svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import {
searchEvent,
searchBySubscription,
searchNip05,
} from "$lib/utils/search_utility";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { nip19 } from "nostr-tools";
import {
activeInboxRelays,
activeOutboxRelays,
getNdkContext,
} from "$lib/ndk";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import { isEventId } from "$lib/utils/nostr_identifiers";
import type { SearchType } from "$lib/models/search_type";
// Props definition
let {
loading,
error,
searchValue,
searchType,
onEventFound,
onSearchResults,
event,
onClear,
onLoadingChange,
}: {
loading: boolean;
error: string | null;
searchValue: string | null;
searchType: SearchType | 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);
// Track current search type for internal logic
let currentSearchType = $state<SearchType | null>(searchType);
let currentSearchValue = $state<string | null>(searchValue);
// Sync internal state with props when they change externally
$effect(() => {
if (searchType !== undefined) {
currentSearchType = searchType;
}
if (searchValue !== undefined) {
currentSearchValue = searchValue;
}
});
// 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 lastProcessedSearchType = $state<SearchType | 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: 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 handleProfileSearch(query: string) {
try {
console.log("EventSearch: Starting profile search for:", query);
// Use the profile search service to find the profile
const { searchProfiles } = await import("$lib/utils/profile_search");
const result = await searchProfiles(query, ndk);
if (result.profiles && result.profiles.length > 0) {
// Get the npub from the profile, or use the original query if profile doesn't have pubkey
let npub = result.profiles[0].pubkey || query;
// Convert npub to hex pubkey
let hexPubkey = "";
try {
if (npub.startsWith('npub')) {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
hexPubkey = decoded.data;
}
} else {
hexPubkey = npub;
}
} catch (error) {
console.warn("Failed to decode npub to hex:", error);
cleanupSearch();
updateSearchState(false, true, 0, "profile");
return;
}
// Fetch the actual profile event from relays
const profileEvent = await ndk.fetchEvent({
kinds: [0],
authors: [hexPubkey],
});
if (profileEvent) {
handleFoundEvent(profileEvent);
updateSearchState(false, true, 1, "profile");
} else {
cleanupSearch();
updateSearchState(false, true, 0, "profile");
}
} else {
console.log("EventSearch: No profile found for:", query);
cleanupSearch();
updateSearchState(false, true, 0, "profile");
}
} catch (error) {
handleSearchError(error, "Profile 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;
}
// Update URL with search query for all search types
if (clearInput) {
const detectedSearchType = getSearchType(query);
if (detectedSearchType) {
const { type, term } = detectedSearchType;
const encoded = encodeURIComponent(term);
let newUrl = "";
if (type === "d") {
newUrl = `?d=${encoded}`;
currentSearchType = "d";
currentSearchValue = term;
} else if (type === "t") {
newUrl = `?t=${encoded}`;
currentSearchType = "t";
currentSearchValue = term;
} else if (type === "n") {
newUrl = `?n=${encoded}`;
currentSearchType = "n";
currentSearchValue = term;
} else if (type === "nip05") {
newUrl = `?q=${encodeURIComponent(query)}`;
currentSearchType = "q";
currentSearchValue = query;
} else if (type === "event") {
newUrl = `?id=${encoded}`;
currentSearchType = "id";
currentSearchValue = term;
}
goto(newUrl, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
} else {
// No specific search type detected, treat as general search
const encoded = encodeURIComponent(query);
const newUrl = `?q=${encoded}`;
currentSearchType = "q";
currentSearchValue = query;
goto(newUrl, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
}
// Handle different search types
const searchType = getSearchType(query);
if (searchType) {
await handleSearchByType(searchType, query, clearInput);
return;
}
// AI-NOTE: If no specific search type is detected, check if it could be an event ID
const trimmedQuery = query.trim();
if (trimmedQuery && isEventId(trimmedQuery)) {
// Looks like an event ID, treat as event search
await handleEventSearch(query);
} else {
// AI-NOTE: Doesn't look like an event ID, treat as generic search
// The URL update logic above should have set currentSearchType = "q"
// For generic "q" searches, we don't perform actual searches since they're
// unstructured queries. We just update the URL for shareability and show completion
// TODO: Handle generic "q" searches with a semantic search capability (when available).
updateSearchState(false, true, 0, "q");
}
}
// AI-NOTE: 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: Detect Nostr identifiers (npub, nevent, naddr, nprofile)
const trimmedQuery = query.trim();
if (trimmedQuery.startsWith("npub") || trimmedQuery.startsWith("nprofile")) {
return { type: "profile", term: trimmedQuery };
}
if (trimmedQuery.startsWith("nevent") || trimmedQuery.startsWith("note")) {
return { type: "event", term: trimmedQuery };
}
if (trimmedQuery.startsWith("naddr")) {
return { type: "event", term: trimmedQuery };
}
// AI-NOTE: Detect hex IDs (64-character hex strings with no spaces)
// These are likely event IDs and should be searched as events
if (trimmedQuery && isEventId(trimmedQuery)) {
return { type: "event", term: trimmedQuery };
}
// AI-NOTE: Treat plain text searches as generic searches by default
// This allows for flexible searching without assuming it's always a profile search
// Users can still use n: prefix for explicit name/profile searches
if (trimmedQuery) {
return null; // Let handleSearchEvent treat this as a generic search
}
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);
// URL navigation is now handled in handleSearchEvent
updateSearchState(false, false, null, null);
return;
}
if (type === "nip05") {
await handleNip05Search(term);
return;
}
if (type === "profile") {
console.log("EventSearch: Processing profile search:", term);
await handleProfileSearch(term);
return;
}
if (type === "event" || type === "id") {
console.log("EventSearch: Processing event ID search:", term);
// URL navigation is now handled in handleSearchEvent
await handleEventSearch(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;
}
// Use internal state if set (from user actions), otherwise use props
const activeSearchType = currentSearchType ?? searchType;
const activeSearchValue = currentSearchValue ?? searchValue;
if (activeSearchValue && activeSearchType) {
if (activeSearchType === "d") {
searchQuery = `d:${activeSearchValue}`;
} else if (activeSearchType === "t") {
searchQuery = `t:${activeSearchValue}`;
} else if (activeSearchType === "n") {
searchQuery = `n:${activeSearchValue}`;
} else {
searchQuery = activeSearchValue;
}
} 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(() => {
// Use internal state if set (from user actions), otherwise use props
const activeSearchType = currentSearchType ?? searchType;
const activeSearchValue = currentSearchValue ?? searchValue;
if (
activeSearchValue &&
activeSearchType &&
!searching &&
!isResetting &&
(activeSearchType !== lastProcessedSearchType ||
activeSearchValue !== lastProcessedSearchValue)
) {
lastProcessedSearchType = activeSearchType;
lastProcessedSearchValue = activeSearchValue;
setTimeout(() => {
if (!searching && !isResetting) {
if (activeSearchType === "d") {
handleSearchBySubscription("d", activeSearchValue);
} else if (activeSearchType === "t") {
handleSearchBySubscription("t", activeSearchValue);
} else if (activeSearchType === "n") {
handleSearchBySubscription("n", activeSearchValue);
} else if (activeSearchType === "id") {
handleEventSearch(activeSearchValue);
}
// Note: "q" (generic) searches are not processed here since they're
// unstructured queries that don't require actual search execution
}
}, 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;
lastProcessedSearchType = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
// Reset internal search state
currentSearchType = null;
currentSearchValue = 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;
}
if (searchType) {
lastProcessedSearchType = searchType;
}
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
onEventFound(event);
}
// AI-NOTE: 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: 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: 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: 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) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 500));
retryCount++;
}
// AI-NOTE: 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: Use original search value for display
false, // AI-NOTE: 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: Use original search value for display
false, // AI-NOTE: 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;
}
if (searchType) {
lastProcessedSearchType = searchType;
}
}
// 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;
// Reset internal search state
currentSearchType = null;
currentSearchValue = null;
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}.`;
}
</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>