diff --git a/src/lib/components/CommentBox.svelte b/src/lib/components/CommentBox.svelte index fbff0f3..6952279 100644 --- a/src/lib/components/CommentBox.svelte +++ b/src/lib/components/CommentBox.svelte @@ -10,6 +10,7 @@ } from "$lib/utils/search_utility"; import { userPubkey } from "$lib/stores/authStore.Svelte"; + import { userStore } from "$lib/stores/userStore"; import type { NDKEvent } from "$lib/utils/nostrUtils"; import { extractRootEventInfo, @@ -67,17 +68,12 @@ } }); + // Get user profile from userStore $effect(() => { - const trimmedPubkey = $userPubkey?.trim(); - const npub = toNpub(trimmedPubkey); - if (npub) { - // Call an async function, but don't make the effect itself async - getUserMetadata(npub).then((metadata) => { - userProfile = metadata; - }); - } else if (trimmedPubkey) { - userProfile = null; - error = "Invalid public key: must be a 64-character hex string."; + const currentUser = $userStore; + if (currentUser?.signedIn && currentUser.profile) { + userProfile = currentUser.profile; + error = null; } else { userProfile = null; error = null; @@ -590,17 +586,20 @@ {userProfile.name { - const img = e.target as HTMLImageElement; - img.src = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(img.alt)}`; - }} + class="w-8 h-8 rounded-full object-cover" + onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'} /> + {:else} +
+ + {(userProfile.displayName || userProfile.name || "U").charAt(0).toUpperCase()} + +
{/if} {userProfile.displayName || userProfile.name || - nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."} + `${$userPubkey?.slice(0, 8)}...${$userPubkey?.slice(-4)}`} {/if} diff --git a/src/lib/components/CommentViewer.svelte b/src/lib/components/CommentViewer.svelte new file mode 100644 index 0000000..3b44665 --- /dev/null +++ b/src/lib/components/CommentViewer.svelte @@ -0,0 +1,904 @@ + + + +{#snippet CommentItem(node: CommentNode)} +
+
+
+
+ +
+ + + {formatRelativeDate(node.event.created_at || 0)} • Kind: {node.event.kind} + +
+
+
+ + {shortenNevent(getNeventUrl(node.event))} + + +
+
+ +
+ {#if node.event.kind === 9802} + +
+ {#if hasHighlightComment(node.event)} + +
+
+ Highlighted content: +
+ {#if node.event.getMatchingTags("context")[0]?.[1]} +
+ {@html node.event.getMatchingTags("context")[0]?.[1]} +
+ {:else} +
+ {node.event.content || ""} +
+ {/if} + {#if getHighlightSource(node.event)} +
+ Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'} +
+ {/if} +
+
+
+ Comment: +
+ {#await parseContent(node.event.getMatchingTags("comment")[0]?.[1] || "") then parsedContent} + {@html parsedContent} + {:catch} + {@html node.event.getMatchingTags("comment")[0]?.[1] || ""} + {/await} +
+ {:else} + + {#if node.event.getMatchingTags("context")[0]?.[1]} +
+ {@html node.event.getMatchingTags("context")[0]?.[1]} +
+ {:else} +
+ {node.event.content || ""} +
+ {/if} + + {#if getHighlightSource(node.event)} +
+ Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'} +
+ {/if} + {/if} + + {#if getHighlightAttribution(node.event).length > 0} +
+ Attribution: + {#each getHighlightAttribution(node.event) as attribution} + + {/each} +
+ {/if} +
+ {:else} + + {#await parseContent(node.event.content || "") then parsedContent} + {@html parsedContent} + {:catch} + {@html node.event.content || ""} + {/await} + {/if} +
+
+ + {#if node.children.length > 0} +
+ {#each node.children as childNode (childNode.event.id)} + {@render CommentItem(childNode)} + {/each} +
+ {/if} +
+{/snippet} + +
+ + Comments & Highlights ({threadedComments.length}) + + + {#if loading} +
+

Loading comments...

+
+ {:else if error} +
+

{error}

+
+ {:else if threadedComments.length === 0} +
+

No comments or highlights yet. Be the first to engage!

+
+ {:else} +
+ {#each threadedComments as node (node.event.id)} + {@render CommentItem(node)} + {/each} +
+ {/if} +
+ + \ No newline at end of file diff --git a/src/lib/components/EventDetails.svelte b/src/lib/components/EventDetails.svelte index 4bd78e4..6e6712a 100644 --- a/src/lib/components/EventDetails.svelte +++ b/src/lib/components/EventDetails.svelte @@ -14,6 +14,7 @@ import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; import { navigateToEvent } from "$lib/utils/nostrEventService"; import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte"; + import Notifications from "$lib/components/Notifications.svelte"; const { event, @@ -400,6 +401,11 @@ {/if} + + {#if event.kind === 0} + + {/if} +
{#if toNpub(event.pubkey)} -
- {#if event.kind !== 0} - Content: -
- {@html showFullContent ? parsedContent : contentPreview} - {#if !showFullContent && parsedContent.length > 250} - - {/if} + {#if event.kind !== 0} +
+
+ Content: +
+ {@html showFullContent ? parsedContent : contentPreview} + {#if !showFullContent && parsedContent.length > 250} + + {/if} +
- {/if} -
+
+ {/if} {#if event.kind === 0} diff --git a/src/lib/components/EventInput.svelte b/src/lib/components/EventInput.svelte index 0519692..cec2cde 100644 --- a/src/lib/components/EventInput.svelte +++ b/src/lib/components/EventInput.svelte @@ -29,6 +29,7 @@ import { Button } from "flowbite-svelte"; import { goto } from "$app/navigation"; import { WebSocketPool } from "$lib/data_structures/websocket_pool"; + import { anonymousRelays } from "$lib/consts"; let kind = $state(30040); let tags = $state<[string, string][]>([]); @@ -384,9 +385,7 @@ // Try to publish to relays directly const relays = [ - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://nos.lol", + ...anonymousRelays, ...$activeOutboxRelays, ...$activeInboxRelays, ]; diff --git a/src/lib/components/EventSearch.svelte b/src/lib/components/EventSearch.svelte index 10f888b..67dece6 100644 --- a/src/lib/components/EventSearch.svelte +++ b/src/lib/components/EventSearch.svelte @@ -488,6 +488,61 @@ 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); @@ -663,6 +718,83 @@ } } + // 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 = ""; diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte new file mode 100644 index 0000000..f0995c7 --- /dev/null +++ b/src/lib/components/Notifications.svelte @@ -0,0 +1,1186 @@ + + +{#if isOwnProfile && $userStore.signedIn} +
+
+ Notifications + +
+ + + + +
+ {#each ["to-me", "from-me", "public-messages"] as mode} + {@const modeLabel = mode === "to-me" ? "To Me" : mode === "from-me" ? "From Me" : "Public Messages"} + + {/each} +
+
+
+ + {#if loading} +
+
+ + Loading {notificationMode === "public-messages" ? "public messages" : "notifications"}... + +
+ {:else if error} +
+

Error loading {notificationMode === "public-messages" ? "public messages" : "notifications"}: {error}

+
+ {:else if notificationMode === "public-messages"} + {#if publicMessages.length === 0} +
+

No public messages found.

+
+ {:else} +
+ {#if filteredByUser} +
+
+ + Filtered by user: {@render userBadge(filteredByUser, authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name)} + + +
+
+ {/if} +
+ {#each filteredMessages.slice(0, 100) as message} + {@const authorProfile = authorProfiles.get(message.pubkey)} + {@const isFromUser = message.pubkey === $userStore.pubkey} +
+
+ +
+
+ {#if authorProfile?.picture} + Author avatar (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else} +
+ + {(authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} + + {@render userBadge(message.pubkey, authorProfile?.displayName || authorProfile?.name)} + +
+ + + {#if !isFromUser} +
+ + + + +
+ {/if} +
+ + +
+
+ + {isFromUser ? 'Your Message' : 'Public Message'} + + + {message.created_at ? formatDate(message.created_at) : "Unknown date"} + + +
+ + + + {#if message.getMatchingTags("q").length > 0} +
+ {#await renderQuotedContent(message, publicMessages) then quotedHtml} + {@html quotedHtml} + {:catch} + + {/await} +
+ {/if} + {#if message.content} +
+ {#await parseContent(message.content) then parsedContent} + {@html parsedContent} + {:catch} + {@html message.content} + {/await} +
+ {/if} + + +
+
+ +
+ {/each} +
+ + {#if filteredMessages.length > 100} +
+ Showing 100 of {filteredMessages.length} messages {filteredByUser ? `(filtered)` : ''}. Scroll to see more. +
+ {/if} +
+ {/if} + {:else} + {#if notifications.length === 0} +
+

No notifications {notificationMode === "to-me" ? "received" : "sent"} found.

+
+ {:else} +
+ {#each notifications.slice(0, 100) as notification} + {@const authorProfile = authorProfiles.get(notification.pubkey)} +
+
+ +
+
+ {#if authorProfile?.picture} + Author avatar (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else} +
+ + {(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} + + {@render userBadge(notification.pubkey, authorProfile?.displayName || authorProfile?.name)} + +
+
+ + +
+
+ + {getNotificationType(notification)} + + + {notification.created_at ? formatDate(notification.created_at) : "Unknown date"} + + +
+ + + + {#if notification.content} +
+ {#await parseContent(notification.content) then parsedContent} + {@html parsedContent} + {:catch} + {@html truncateContent(notification.content)} + {/await} +
+ {/if} + + +
+
+
+ {/each} + + {#if notifications.length > 100} +
+ Showing 100 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more. +
+ {/if} +
+ {/if} + {/if} +
+ + + +
+
+

+ {replyToMessage ? 'Reply to Message' : 'New Public Message'} +

+
+ + + {#if quotedContent} +
+
Replying to:
+
+ {#await parseContent(quotedContent) then parsedContent} + {@html parsedContent} + {:catch} + {@html quotedContent} + {/await} +
+
+ {/if} + + +
+
+ + Sending to {selectedRecipients.length} recipient{selectedRecipients.length !== 1 ? 's' : ''}: + + +
+ + {#if selectedRecipients.length === 0} +
+

+ No recipients selected. Click "Edit Recipients" to add recipients. +

+
+ {:else} +
+ {#each selectedRecipients as recipient} + + {@render userBadge(recipient.pubkey!, recipient.displayName || recipient.name)} + + + {/each} +
+ {/if} +
+ + + {#if selectedRecipients.length > 0 && newMessageRelays.length > 0} +
+ + Publishing to {newMessageRelays.length} relay{newMessageRelays.length !== 1 ? 's' : ''}: + +
+
+ {#each newMessageRelays as relay} +
+ {relay} +
+ {/each} +
+
+
+ {/if} + + +
+ + +
+ + +
+ + +
+
+
+ + + +
+
+

Select Recipients

+
+ +
+
+ + {#if recipientLoading} +
+
+
+ {/if} +
+ + {#if recipientResults.length > 0} +
+
    + {#each recipientResults as profile} + {@const isAlreadySelected = selectedRecipients.some(r => r.pubkey === profile.pubkey)} + + {/each} +
+
+ {:else if recipientSearch.trim()} +
No results found
+ {:else} +
+ Enter a search term to find users +
+ {/if} +
+
+
+{/if} \ No newline at end of file diff --git a/src/lib/components/RelayInfoDisplay.svelte b/src/lib/components/RelayInfoDisplay.svelte new file mode 100644 index 0000000..c72dc24 --- /dev/null +++ b/src/lib/components/RelayInfoDisplay.svelte @@ -0,0 +1,92 @@ + + +
+ {#if showIcon && relayIcon} + Relay icon (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else if showIcon} + +
+ + + +
+ {/if} + +
+ {#if showName} + + {isLoading ? 'Loading...' : displayName} + + {/if} + + {#if showType} + + {relayType} + + {/if} +
+ + {#if error} + + ⚠️ + + {/if} +
diff --git a/src/lib/components/RelayInfoList.svelte b/src/lib/components/RelayInfoList.svelte new file mode 100644 index 0000000..62d6b8b --- /dev/null +++ b/src/lib/components/RelayInfoList.svelte @@ -0,0 +1,143 @@ + + +
+ {#if showLabels && !compact} + {@const categorizedCount = categorizedRelays().length} +
+ Publishing to {categorizedCount} relay(s): +
+ {/if} + + {#if isLoading} +
+
+ Loading relay info... +
+ {:else} + {@const categorized = categorizedRelays()} + +
+ {#each categorized as { relay, category, label }} +
+
+ + {relay} + + {#if category === 'both'} + + common relay + + {/if} +
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 90afa53..b8e7f0d 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -14,6 +14,9 @@ export const searchRelays = [ "wss://aggr.nostr.land", "wss://relay.noswhere.com", "wss://nostr.wine", + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://freelay.sovbit.host" ]; export const secondaryRelays = [ @@ -30,6 +33,8 @@ export const secondaryRelays = [ export const anonymousRelays = [ "wss://freelay.sovbit.host", "wss://thecitadel.nostr1.com", + "wss://relay.damus.io", + "wss://relay.nostr.band" ]; export const lowbandwidthRelays = [ @@ -48,5 +53,7 @@ export enum FeedType { UserRelays = "user", } +export const EXPIRATION_DURATION = 28 * 24 * 60 * 60; // 4 weeks in seconds + export const loginStorageKey = "alexandria/login/pubkey"; export const feedTypeStorageKey = "alexandria/feed/type"; diff --git a/src/lib/ndk.ts b/src/lib/ndk.ts index 70592ba..f89a9f7 100644 --- a/src/lib/ndk.ts +++ b/src/lib/ndk.ts @@ -33,6 +33,63 @@ export const outboxRelays = writable([]); export const activeInboxRelays = writable([]); export const activeOutboxRelays = writable([]); +// AI-NOTE: 2025-01-08 - Persistent relay storage to avoid recalculation +let persistentRelaySet: { inboxRelays: string[]; outboxRelays: string[] } | null = null; +let relaySetLastUpdated: number = 0; +const RELAY_SET_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +const RELAY_SET_STORAGE_KEY = 'alexandria/relay_set_cache'; + +/** + * Load persistent relay set from localStorage + */ +function loadPersistentRelaySet(): { relaySet: { inboxRelays: string[]; outboxRelays: string[] } | null; lastUpdated: number } { + try { + const stored = localStorage.getItem(RELAY_SET_STORAGE_KEY); + if (!stored) return { relaySet: null, lastUpdated: 0 }; + + const data = JSON.parse(stored); + const now = Date.now(); + + // Check if cache is expired + if (now - data.timestamp > RELAY_SET_CACHE_DURATION) { + localStorage.removeItem(RELAY_SET_STORAGE_KEY); + return { relaySet: null, lastUpdated: 0 }; + } + + return { relaySet: data.relaySet, lastUpdated: data.timestamp }; + } catch (error) { + console.warn('[NDK.ts] Failed to load persistent relay set:', error); + localStorage.removeItem(RELAY_SET_STORAGE_KEY); + return { relaySet: null, lastUpdated: 0 }; + } +} + +/** + * Save persistent relay set to localStorage + */ +function savePersistentRelaySet(relaySet: { inboxRelays: string[]; outboxRelays: string[] }): void { + try { + const data = { + relaySet, + timestamp: Date.now() + }; + localStorage.setItem(RELAY_SET_STORAGE_KEY, JSON.stringify(data)); + } catch (error) { + console.warn('[NDK.ts] Failed to save persistent relay set:', error); + } +} + +/** + * Clear persistent relay set from localStorage + */ +function clearPersistentRelaySet(): void { + try { + localStorage.removeItem(RELAY_SET_STORAGE_KEY); + } catch (error) { + console.warn('[NDK.ts] Failed to clear persistent relay set:', error); + } +} + // Subscribe to userStore changes and update ndkSignedIn accordingly userStore.subscribe((userState) => { ndkSignedIn.set(userState.signedIn); @@ -351,15 +408,39 @@ export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string /** * Updates the active relay stores and NDK pool with new relay URLs * @param ndk NDK instance + * @param forceUpdate Force update even if cached (default: false) */ -export async function updateActiveRelayStores(ndk: NDK): Promise { +export async function updateActiveRelayStores(ndk: NDK, forceUpdate: boolean = false): Promise { try { + // AI-NOTE: 2025-01-08 - Use persistent relay set to avoid recalculation + const now = Date.now(); + const cacheExpired = now - relaySetLastUpdated > RELAY_SET_CACHE_DURATION; + + // Load from persistent storage if not already loaded + if (!persistentRelaySet) { + const loaded = loadPersistentRelaySet(); + persistentRelaySet = loaded.relaySet; + relaySetLastUpdated = loaded.lastUpdated; + } + + if (!forceUpdate && persistentRelaySet && !cacheExpired) { + console.debug('[NDK.ts] updateActiveRelayStores: Using cached relay set'); + activeInboxRelays.set(persistentRelaySet.inboxRelays); + activeOutboxRelays.set(persistentRelaySet.outboxRelays); + return; + } + console.debug('[NDK.ts] updateActiveRelayStores: Starting relay store update'); // Get the active relay set from the relay management system const relaySet = await getActiveRelaySet(ndk); console.debug('[NDK.ts] updateActiveRelayStores: Got relay set:', relaySet); + // Cache the relay set + persistentRelaySet = relaySet; + relaySetLastUpdated = now; + savePersistentRelaySet(relaySet); // Save to persistent storage + // Update the stores with the new relay configuration activeInboxRelays.set(relaySet.inboxRelays); activeOutboxRelays.set(relaySet.outboxRelays); @@ -560,6 +641,11 @@ export function logout(user: NDKUser): void { activeInboxRelays.set([]); activeOutboxRelays.set([]); + // AI-NOTE: 2025-01-08 - Clear persistent relay set on logout + persistentRelaySet = null; + relaySetLastUpdated = 0; + clearPersistentRelaySet(); // Clear persistent storage + // Stop network monitoring stopNetworkStatusMonitoring(); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ee44929..60237f8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -19,12 +19,19 @@ export class InvalidKindError extends DecodeError { } export function neventEncode(event: NDKEvent, relays: string[]) { - return nip19.neventEncode({ - id: event.id, - kind: event.kind, - relays, - author: event.pubkey, - }); + try { + const nevent = nip19.neventEncode({ + id: event.id, + kind: event.kind, + relays, + author: event.pubkey, + }); + + return nevent; + } catch (error) { + console.error(`[neventEncode] Error encoding nevent:`, error); + throw error; + } } export function naddrEncode(event: NDKEvent, relays: string[]) { diff --git a/src/lib/utils/event_search.ts b/src/lib/utils/event_search.ts index 1d5537d..f15b9b3 100644 --- a/src/lib/utils/event_search.ts +++ b/src/lib/utils/event_search.ts @@ -1,5 +1,5 @@ import { ndkInstance } from "../ndk.ts"; -import { fetchEventWithFallback } from "./nostrUtils.ts"; +import { fetchEventWithFallback, NDKRelaySetFromNDK } from "./nostrUtils.ts"; import { nip19 } from "nostr-tools"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import type { Filter } from "./search_types.ts"; @@ -11,6 +11,26 @@ import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; * Search for a single event by ID or filter */ export async function searchEvent(query: string): Promise { + const ndk = get(ndkInstance); + if (!ndk) { + console.warn("[Search] No NDK instance available"); + return null; + } + + // Wait for relays to be available + let attempts = 0; + const maxAttempts = 10; + while (ndk.pool.relays.size === 0 && attempts < maxAttempts) { + console.log(`[Search] Waiting for relays to be available (attempt ${attempts + 1}/${maxAttempts})`); + await new Promise(resolve => setTimeout(resolve, 500)); + attempts++; + } + + if (ndk.pool.relays.size === 0) { + console.warn("[Search] No relays available after waiting"); + return null; + } + // Clean the query and normalize to lowercase const cleanedQuery = query.replace(/^nostr:/, "").toLowerCase(); let filterOrId: Filter | string = cleanedQuery; @@ -51,8 +71,50 @@ export async function searchEvent(query: string): Promise { try { const decoded = nip19.decode(cleanedQuery); if (!decoded) throw new Error("Invalid identifier"); + + console.log(`[Search] Decoded identifier:`, { + type: decoded.type, + data: decoded.data, + query: cleanedQuery + }); + switch (decoded.type) { case "nevent": + console.log(`[Search] Processing nevent:`, { + id: decoded.data.id, + kind: decoded.data.kind, + relays: decoded.data.relays + }); + + // Use the relays from the nevent if available + if (decoded.data.relays && decoded.data.relays.length > 0) { + console.log(`[Search] Using relays from nevent:`, decoded.data.relays); + + // Try to fetch the event using the nevent's relays + try { + // Create a temporary relay set for this search + const neventRelaySet = NDKRelaySetFromNDK.fromRelayUrls(decoded.data.relays, ndk); + + if (neventRelaySet.relays.size > 0) { + console.log(`[Search] Created relay set with ${neventRelaySet.relays.size} relays from nevent`); + + // Try to fetch the event using the nevent's relays + const event = await ndk + .fetchEvent({ ids: [decoded.data.id] }, undefined, neventRelaySet) + .withTimeout(TIMEOUTS.EVENT_FETCH); + + if (event) { + console.log(`[Search] Found event using nevent relays:`, event.id); + return event; + } else { + console.log(`[Search] Event not found on nevent relays, trying default relays`); + } + } + } catch (error) { + console.warn(`[Search] Error fetching from nevent relays:`, error); + } + } + filterOrId = decoded.data.id; break; case "note": diff --git a/src/lib/utils/kind24_utils.ts b/src/lib/utils/kind24_utils.ts new file mode 100644 index 0000000..2dada04 --- /dev/null +++ b/src/lib/utils/kind24_utils.ts @@ -0,0 +1,135 @@ +import { get } from "svelte/store"; +import { ndkInstance } from "../ndk"; +import { userStore } from "../stores/userStore"; +import { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; +import type NDK from "@nostr-dev-kit/ndk"; +import { nip19 } from "nostr-tools"; +import { createSignedEvent } from "./nostrEventService.ts"; +import { anonymousRelays } from "../consts"; +import { buildCompleteRelaySet } from "./relay_management"; + +// AI-NOTE: Using existing relay utilities from relay_management.ts instead of duplicating functionality + +/** + * Gets optimal relay set for kind 24 messages between two users + * @param senderPubkey The sender's pubkey + * @param recipientPubkey The recipient's pubkey + * @returns Promise resolving to relay URLs prioritized by commonality + */ +export async function getKind24RelaySet( + senderPubkey: string, + recipientPubkey: string +): Promise { + const ndk = get(ndkInstance); + if (!ndk) { + throw new Error("NDK not available"); + } + + const senderPrefix = senderPubkey.slice(0, 8); + const recipientPrefix = recipientPubkey.slice(0, 8); + + console.log(`[getKind24RelaySet] Getting relays for ${senderPrefix} -> ${recipientPrefix}`); + + try { + // Fetch both users' complete relay sets using existing utilities + const [senderRelaySet, recipientRelaySet] = await Promise.all([ + buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: senderPubkey })), + buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: recipientPubkey })) + ]); + + // Use sender's outbox relays and recipient's inbox relays + const senderOutboxRelays = senderRelaySet.outboxRelays; + const recipientInboxRelays = recipientRelaySet.inboxRelays; + + // Prioritize common relays for better privacy + const commonRelays = senderOutboxRelays.filter(relay => + recipientInboxRelays.includes(relay) + ); + const senderOnlyRelays = senderOutboxRelays.filter(relay => + !recipientInboxRelays.includes(relay) + ); + const recipientOnlyRelays = recipientInboxRelays.filter(relay => + !senderOutboxRelays.includes(relay) + ); + + // Prioritize: common relays first, then sender outbox, then recipient inbox + const finalRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays]; + + console.log(`[getKind24RelaySet] ${senderPrefix}->${recipientPrefix} - Common: ${commonRelays.length}, Sender-only: ${senderOnlyRelays.length}, Recipient-only: ${recipientOnlyRelays.length}, Total: ${finalRelays.length}`); + + return finalRelays; + } catch (error) { + console.error(`[getKind24RelaySet] Error getting relay set for ${senderPrefix}->${recipientPrefix}:`, error); + throw error; + } +} + +/** + * Creates a kind 24 public message reply according to NIP-A4 + * @param content The message content + * @param recipientPubkey The recipient's pubkey + * @param originalEvent The original event being replied to (optional) + * @returns Promise resolving to publish result with relay information + */ +export async function createKind24Reply( + content: string, + recipientPubkey: string, + originalEvent?: NDKEvent +): Promise<{ success: boolean; eventId?: string; error?: string; relays?: string[] }> { + const ndk = get(ndkInstance); + if (!ndk?.activeUser) { + return { success: false, error: "Not logged in" }; + } + + if (!content.trim()) { + return { success: false, error: "Message content cannot be empty" }; + } + + try { + // Get optimal relay set for this sender-recipient pair + const targetRelays = await getKind24RelaySet(ndk.activeUser.pubkey, recipientPubkey); + + if (targetRelays.length === 0) { + return { success: false, error: "No relays available for publishing" }; + } + + // Build tags for the kind 24 event + const tags: string[][] = [ + ["p", recipientPubkey, targetRelays[0]] // Use first relay as primary + ]; + + // Add q tag if replying to an original event + if (originalEvent) { + tags.push(["q", originalEvent.id, targetRelays[0] || anonymousRelays[0]]); + } + + // Create and sign the event + const { event: signedEventData } = await createSignedEvent( + content, + ndk.activeUser.pubkey, + 24, + tags + ); + + // Create NDKEvent and publish + const event = new NDKEvent(ndk, signedEventData); + const relaySet = NDKRelaySet.fromRelayUrls(targetRelays, ndk); + const publishedToRelays = await event.publish(relaySet); + + if (publishedToRelays.size > 0) { + console.log(`[createKind24Reply] Successfully published to ${publishedToRelays.size} relays`); + return { success: true, eventId: event.id, relays: targetRelays }; + } else { + console.warn(`[createKind24Reply] Failed to publish to any relays`); + return { success: false, error: "Failed to publish to any relays", relays: targetRelays }; + } + } catch (error) { + console.error("[createKind24Reply] Error creating kind 24 reply:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }; + } +} + + diff --git a/src/lib/utils/markup/basicMarkupParser.ts b/src/lib/utils/markup/basicMarkupParser.ts index fd7fd14..2d70c41 100644 --- a/src/lib/utils/markup/basicMarkupParser.ts +++ b/src/lib/utils/markup/basicMarkupParser.ts @@ -78,20 +78,7 @@ function replaceAlexandriaNostrLinks(text: string): string { return `nostr:${bech32Match[0]}`; } } - // For non-Alexandria/localhost URLs, append (View here: nostr:) if a Nostr identifier is present - const hexMatch = url.match(hexPattern); - if (hexMatch) { - try { - const nevent = nip19.neventEncode({ id: hexMatch[0] }); - return `${url} (View here: nostr:${nevent})`; - } catch { - return url; - } - } - const bech32Match = url.match(bech32Pattern); - if (bech32Match) { - return `${url} (View here: nostr:${bech32Match[0]})`; - } + // For non-Alexandria/localhost URLs, just return the URL as-is return url; }); @@ -253,7 +240,18 @@ function processBasicFormatting(content: string): string { } // Only render if the url ends with a direct image extension if (IMAGE_EXTENSIONS.test(url.split("?")[0])) { - return `${alt}`; + return `
+
+
+
🖼️
+
Image
+
+
+ ${alt} + +
`; } // Otherwise, render as a clickable link return `${alt || url}`; @@ -290,7 +288,18 @@ function processBasicFormatting(content: string): string { } // Only render if the url ends with a direct image extension if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) { - return `Embedded media`; + return `
+
+
+
🖼️
+
Image
+
+
+ Embedded media + +
`; } // Otherwise, render as a clickable link return `${clean}`; @@ -310,10 +319,10 @@ function processBasicFormatting(content: string): string { }, ); - // Process hashtags + // Process hashtags as clickable buttons processedText = processedText.replace( HASHTAG_REGEX, - '#$1', + '', ); // --- Improved List Grouping and Parsing --- diff --git a/src/lib/utils/nostrEventService.ts b/src/lib/utils/nostrEventService.ts index cdea5e1..459275c 100644 --- a/src/lib/utils/nostrEventService.ts +++ b/src/lib/utils/nostrEventService.ts @@ -3,6 +3,7 @@ import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils.ts"; import { get } from "svelte/store"; import { goto } from "$app/navigation"; import { EVENT_KINDS, TIME_CONSTANTS } from "./search_constants.ts"; +import { EXPIRATION_DURATION } from "../consts.ts"; import { ndkInstance } from "../ndk.ts"; import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; @@ -320,12 +321,19 @@ export async function createSignedEvent( ): Promise<{ id: string; sig: string; event: any }> { const prefixedContent = prefixNostrAddresses(content); + // Add expiration tag for kind 24 events (NIP-40) + const finalTags = [...tags]; + if (kind === 24) { + const expirationTimestamp = Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR) + EXPIRATION_DURATION; + finalTags.push(["expiration", String(expirationTimestamp)]); + } + const eventToSign = { kind: Number(kind), created_at: Number( Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR), ), - tags: tags.map((tag) => [ + tags: finalTags.map((tag) => [ String(tag[0]), String(tag[1]), String(tag[2] || ""), diff --git a/src/lib/utils/notification_utils.ts b/src/lib/utils/notification_utils.ts new file mode 100644 index 0000000..d90e7f4 --- /dev/null +++ b/src/lib/utils/notification_utils.ts @@ -0,0 +1,225 @@ +import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; +import type { NDKEvent } from "$lib/utils/nostrUtils"; +import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUtils"; +import { get } from "svelte/store"; +import { ndkInstance } from "$lib/ndk"; +import { searchRelays } from "$lib/consts"; +import { userStore } from "$lib/stores/userStore"; +import { buildCompleteRelaySet } from "$lib/utils/relay_management"; +import { neventEncode } from "$lib/utils"; + +// AI-NOTE: Notification-specific utility functions that don't exist elsewhere + +/** + * Truncates content to a specified length + */ +export function truncateContent(content: string, maxLength: number = 300): string { + if (content.length <= maxLength) return content; + return content.slice(0, maxLength) + "..."; +} + +/** + * Truncates rendered HTML content while preserving quote boxes + */ +export function truncateRenderedContent(renderedHtml: string, maxLength: number = 300): string { + if (renderedHtml.length <= maxLength) return renderedHtml; + + const hasQuoteBoxes = renderedHtml.includes('jump-to-message'); + + if (hasQuoteBoxes) { + const quoteBoxPattern = /
]*>[^<]*<\/div>/g; + const quoteBoxes = renderedHtml.match(quoteBoxPattern) || []; + + let textOnly = renderedHtml.replace(quoteBoxPattern, '|||QUOTEBOX|||'); + + if (textOnly.length > maxLength) { + const availableLength = maxLength - (quoteBoxes.join('').length); + if (availableLength > 50) { + textOnly = textOnly.slice(0, availableLength) + "..."; + } else { + textOnly = textOnly.slice(0, 50) + "..."; + } + } + + let result = textOnly; + quoteBoxes.forEach(box => { + result = result.replace('|||QUOTEBOX|||', box); + }); + + return result; + } else { + if (renderedHtml.includes('<')) { + const truncated = renderedHtml.slice(0, maxLength); + const lastTagStart = truncated.lastIndexOf('<'); + const lastTagEnd = truncated.lastIndexOf('>'); + + if (lastTagStart > lastTagEnd) { + return renderedHtml.slice(0, lastTagStart) + "..."; + } + return truncated + "..."; + } else { + return renderedHtml.slice(0, maxLength) + "..."; + } + } +} + +/** + * Parses content using basic markup parser + */ +export async function parseContent(content: string): Promise { + if (!content) return ""; + return await parseBasicmarkup(content); +} + +/** + * Renders quoted content for a message + */ +export async function renderQuotedContent(message: NDKEvent, publicMessages: NDKEvent[]): Promise { + const qTags = message.getMatchingTags("q"); + if (qTags.length === 0) return ""; + + const qTag = qTags[0]; + const eventId = qTag[1]; + + if (eventId) { + // First try to find in local messages + let quotedMessage = publicMessages.find(msg => msg.id === eventId); + + // If not found locally, fetch from relays + if (!quotedMessage) { + try { + const ndk = get(ndkInstance); + if (ndk) { + const userStoreValue = get(userStore); + const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; + const relaySet = await buildCompleteRelaySet(ndk, user); + const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays, ...searchRelays]; + + if (allRelays.length > 0) { + const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk); + const fetchedEvent = await ndk.fetchEvent({ ids: [eventId], limit: 1 }, undefined, ndkRelaySet); + quotedMessage = fetchedEvent || undefined; + } + } + } catch (error) { + console.warn(`[renderQuotedContent] Failed to fetch quoted event ${eventId}:`, error); + } + } + + if (quotedMessage) { + const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"; + const parsedContent = await parseBasicmarkup(quotedContent); + return `
${parsedContent}
`; + } else { + // Fallback to nevent link + const nevent = neventEncode({ id: eventId } as any, []); + return `
Quoted message not found. Click to view event ${eventId.slice(0, 8)}...
`; + } + } + + return ""; +} + +/** + * Gets notification type based on event kind + */ +export function getNotificationType(event: NDKEvent): string { + switch (event.kind) { + case 1: return "Reply"; + case 1111: return "Custom Reply"; + case 9802: return "Highlight"; + case 6: return "Repost"; + case 16: return "Generic Repost"; + case 24: return "Public Message"; + default: return `Kind ${event.kind}`; + } +} + +/** + * Fetches author profiles for a list of events + */ +export async function fetchAuthorProfiles(events: NDKEvent[]): Promise> { + const authorProfiles = new Map(); + const uniquePubkeys = new Set(); + + events.forEach(event => { + if (event.pubkey) uniquePubkeys.add(event.pubkey); + }); + + const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => { + try { + const npub = toNpub(pubkey); + if (!npub) return; + + // Try cache first + let profile = await getUserMetadata(npub, false); + if (profile && (profile.name || profile.displayName || profile.picture)) { + authorProfiles.set(pubkey, profile); + return; + } + + // Try search relays + for (const relay of searchRelays) { + try { + const ndk = get(ndkInstance); + if (!ndk) break; + + const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk); + const profileEvent = await ndk.fetchEvent( + { kinds: [0], authors: [pubkey] }, + undefined, + relaySet + ); + + if (profileEvent) { + const profileData = JSON.parse(profileEvent.content); + authorProfiles.set(pubkey, { + name: profileData.name, + displayName: profileData.display_name || profileData.displayName, + picture: profileData.picture || profileData.image + }); + return; + } + } catch (error) { + console.warn(`[fetchAuthorProfiles] Failed to fetch profile from ${relay}:`, error); + } + } + + // Try all available relays as fallback + try { + const ndk = get(ndkInstance); + if (!ndk) return; + + const userStoreValue = get(userStore); + const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; + const relaySet = await buildCompleteRelaySet(ndk, user); + const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays]; + + if (allRelays.length > 0) { + const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk); + const profileEvent = await ndk.fetchEvent( + { kinds: [0], authors: [pubkey] }, + undefined, + ndkRelaySet + ); + + if (profileEvent) { + const profileData = JSON.parse(profileEvent.content); + authorProfiles.set(pubkey, { + name: profileData.name, + displayName: profileData.display_name || profileData.displayName, + picture: profileData.picture || profileData.image + }); + } + } + } catch (error) { + console.warn(`[fetchAuthorProfiles] Failed to fetch profile from all relays:`, error); + } + } catch (error) { + console.warn(`[fetchAuthorProfiles] Failed to fetch profile for ${pubkey}:`, error); + } + }); + + await Promise.allSettled(profilePromises); + return authorProfiles; +} diff --git a/src/lib/utils/npubCache.ts b/src/lib/utils/npubCache.ts index 4fc4405..8c1c36f 100644 --- a/src/lib/utils/npubCache.ts +++ b/src/lib/utils/npubCache.ts @@ -4,6 +4,47 @@ export type NpubMetadata = NostrProfile; class NpubCache { private cache: Record = {}; + private readonly storageKey = 'alexandria_npub_cache'; + private readonly maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + + constructor() { + this.loadFromStorage(); + } + + private loadFromStorage(): void { + try { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(this.storageKey); + if (stored) { + const data = JSON.parse(stored) as Record; + const now = Date.now(); + + // Filter out expired entries + for (const [key, entry] of Object.entries(data)) { + if (entry.timestamp && (now - entry.timestamp) < this.maxAge) { + this.cache[key] = entry.profile; + } + } + } + } + } catch (error) { + console.warn('Failed to load npub cache from storage:', error); + } + } + + private saveToStorage(): void { + try { + if (typeof window !== 'undefined') { + const data: Record = {}; + for (const [key, profile] of Object.entries(this.cache)) { + data[key] = { profile, timestamp: Date.now() }; + } + localStorage.setItem(this.storageKey, JSON.stringify(data)); + } + } catch (error) { + console.warn('Failed to save npub cache to storage:', error); + } + } get(key: string): NpubMetadata | undefined { return this.cache[key]; @@ -11,6 +52,7 @@ class NpubCache { set(key: string, value: NpubMetadata): void { this.cache[key] = value; + this.saveToStorage(); } has(key: string): boolean { @@ -20,6 +62,7 @@ class NpubCache { delete(key: string): boolean { if (key in this.cache) { delete this.cache[key]; + this.saveToStorage(); return true; } return false; @@ -37,6 +80,7 @@ class NpubCache { clear(): void { this.cache = {}; + this.saveToStorage(); } size(): number { diff --git a/src/lib/utils/profile_search.ts b/src/lib/utils/profile_search.ts index eeac332..ecf43ec 100644 --- a/src/lib/utils/profile_search.ts +++ b/src/lib/utils/profile_search.ts @@ -1,8 +1,8 @@ -import { ndkInstance } from "../ndk.ts"; +import { ndkInstance, activeInboxRelays } from "../ndk.ts"; import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts"; import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import { searchCache } from "./searchCache.ts"; -import { communityRelays, secondaryRelays } from "../consts.ts"; +import { searchRelays, communityRelays, secondaryRelays } from "../consts.ts"; import { get } from "svelte/store"; import type { NostrProfile, ProfileSearchResult } from "./search_types.ts"; import { @@ -264,12 +264,21 @@ async function quickRelaySearch( const normalizedSearchTerm = normalizeSearchTerm(searchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm); - // Use all profile relays for better coverage - const quickRelayUrls = [...communityRelays, ...secondaryRelays]; // Use all available relays - console.log("Using all relays for search:", quickRelayUrls); + // Use search relays (optimized for profiles) + user's inbox relays + community relays + const userInboxRelays = get(activeInboxRelays); + const quickRelayUrls = [ + ...searchRelays, // Dedicated profile search relays + ...userInboxRelays, // User's personal inbox relays + ...communityRelays, // Community relays + ...secondaryRelays // Secondary relays as fallback + ]; + + // Deduplicate relay URLs + const uniqueRelayUrls = [...new Set(quickRelayUrls)]; + console.log("Using relays for profile search:", uniqueRelayUrls); // Create relay sets for parallel search - const relaySets = quickRelayUrls + const relaySets = uniqueRelayUrls .map((url) => { try { return NDKRelaySet.fromRelayUrls([url], ndk); @@ -289,7 +298,7 @@ async function quickRelaySearch( let eventCount = 0; console.log( - `Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`, + `Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`, ); const sub = ndk.subscribe( @@ -354,7 +363,7 @@ async function quickRelaySearch( sub.on("eose", () => { console.log( - `Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`, + `Relay ${index + 1} (${uniqueRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`, ); resolve(foundInRelay); }); @@ -362,7 +371,7 @@ async function quickRelaySearch( // Short timeout for quick search setTimeout(() => { console.log( - `Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`, + `Relay ${index + 1} (${uniqueRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`, ); sub.stop(); resolve(foundInRelay); diff --git a/src/lib/utils/relay_info_service.ts b/src/lib/utils/relay_info_service.ts new file mode 100644 index 0000000..8b978a0 --- /dev/null +++ b/src/lib/utils/relay_info_service.ts @@ -0,0 +1,166 @@ +/** + * Simplifies a URL by removing protocol and common prefixes + * @param url The URL to simplify + * @returns Simplified URL string + */ +function simplifyUrl(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.hostname + (urlObj.port ? `:${urlObj.port}` : ''); + } catch { + // If URL parsing fails, return the original string + return url; + } +} + +export interface RelayInfo { + name?: string; + description?: string; + icon?: string; + pubkey?: string; + contact?: string; + supported_nips?: number[]; + software?: string; + version?: string; + tags?: string[]; + payments_url?: string; + limitation?: { + auth_required?: boolean; + payment_required?: boolean; + }; +} + +export interface RelayInfoWithMetadata extends RelayInfo { + url: string; + shortUrl: string; + hasNip11: boolean; + triedNip11: boolean; +} + +/** + * Fetches relay information using NIP-11 + * @param url The relay URL to fetch info for + * @returns Promise resolving to relay info or undefined if failed + */ +export async function fetchRelayInfo(url: string): Promise { + try { + // Convert WebSocket URL to HTTP URL for NIP-11 + const httpUrl = url.replace('ws://', 'http://').replace('wss://', 'https://'); + + const response = await fetch(httpUrl, { + headers: { + 'Accept': 'application/nostr+json', + 'User-Agent': 'Alexandria/1.0' + }, + // Add timeout to prevent hanging + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + console.warn(`[RelayInfo] HTTP ${response.status} for ${url}`); + return { + url, + shortUrl: simplifyUrl(url), + hasNip11: false, + triedNip11: true + }; + } + + const relayInfo = await response.json() as RelayInfo; + + return { + ...relayInfo, + url, + shortUrl: simplifyUrl(url), + hasNip11: Object.keys(relayInfo).length > 0, + triedNip11: true + }; + } catch (error) { + console.warn(`[RelayInfo] Failed to fetch info for ${url}:`, error); + return { + url, + shortUrl: simplifyUrl(url), + hasNip11: false, + triedNip11: true + }; + } +} + +/** + * Fetches relay information for multiple relays in parallel + * @param urls Array of relay URLs to fetch info for + * @returns Promise resolving to array of relay info objects + */ +export async function fetchRelayInfos(urls: string[]): Promise { + if (urls.length === 0) { + return []; + } + + const promises = urls.map(url => fetchRelayInfo(url)); + const results = await Promise.allSettled(promises); + + return results + .map(result => result.status === 'fulfilled' ? result.value : undefined) + .filter((info): info is RelayInfoWithMetadata => info !== undefined); +} + +/** + * Gets relay type label based on relay URL and info + * @param relayUrl The relay URL + * @param relayInfo Optional relay info + * @returns String describing the relay type + */ +export function getRelayTypeLabel(relayUrl: string, relayInfo?: RelayInfoWithMetadata): string { + // Check if it's a local relay + if (relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')) { + return 'Local'; + } + + // Check if it's a community relay + if (relayUrl.includes('nostr.band') || relayUrl.includes('noswhere.com') || + relayUrl.includes('damus.io') || relayUrl.includes('nostr.wine')) { + return 'Community'; + } + + // Check if it's a user's relay (likely inbox/outbox) + if (relayUrl.includes('relay.nsec.app') || relayUrl.includes('relay.snort.social')) { + return 'User'; + } + + // Use relay name if available + if (relayInfo?.name) { + return relayInfo.name; + } + + // Fallback to domain + try { + const domain = new URL(relayUrl).hostname; + return domain.replace('www.', ''); + } catch { + return 'Unknown'; + } +} + +/** + * Gets relay icon URL or fallback + * @param relayInfo Relay info object + * @param relayUrl Relay URL as fallback + * @returns Icon URL or undefined + */ +export function getRelayIcon(relayInfo?: RelayInfoWithMetadata, relayUrl?: string): string | undefined { + if (relayInfo?.icon) { + return relayInfo.icon; + } + + // Generate favicon URL from relay URL + if (relayUrl) { + try { + const url = new URL(relayUrl); + return `${url.protocol}//${url.hostname}/favicon.ico`; + } catch { + // Invalid URL, return undefined + } + } + + return undefined; +} diff --git a/src/lib/utils/search_constants.ts b/src/lib/utils/search_constants.ts index 663e985..cc6f677 100644 --- a/src/lib/utils/search_constants.ts +++ b/src/lib/utils/search_constants.ts @@ -17,7 +17,7 @@ export const TIMEOUTS = { SUBSCRIPTION_SEARCH: 10000, /** Timeout for second-order search operations */ - SECOND_ORDER_SEARCH: 5000, + SECOND_ORDER_SEARCH: 3000, // AI-NOTE: 2025-01-24 - Reduced timeout since we limit scope /** Timeout for relay diagnostics */ RELAY_DIAGNOSTICS: 5000, @@ -44,7 +44,10 @@ export const SEARCH_LIMITS = { SPECIFIC_PROFILE: 10, /** Limit for general profile searches */ - GENERAL_PROFILE: 500, + GENERAL_PROFILE: 100, // AI-NOTE: 2025-01-24 - Reduced from 500 to prevent wild searches + + /** Limit for general content searches (t-tag, d-tag, etc.) */ + GENERAL_CONTENT: 100, // AI-NOTE: 2025-01-24 - Added limit for all content searches /** Limit for community relay checks */ COMMUNITY_CHECK: 1, diff --git a/src/lib/utils/subscription_search.ts b/src/lib/utils/subscription_search.ts index 17fa093..d07067e 100644 --- a/src/lib/utils/subscription_search.ts +++ b/src/lib/utils/subscription_search.ts @@ -26,6 +26,17 @@ const normalizeUrl = (url: string): string => { return url.replace(/\/$/, ''); // Remove trailing slash }; +/** + * Filter out unwanted events from search results + * @param events Array of NDKEvent to filter + * @returns Filtered array of NDKEvent + */ +function filterUnwantedEvents(events: NDKEvent[]): NDKEvent[] { + return events.filter( + (event) => !isEmojiReaction(event) && event.kind !== 3 && event.kind !== 5, + ); +} + /** * Search for events by subscription type (d, t, n) */ @@ -35,6 +46,7 @@ export async function searchBySubscription( callbacks?: SearchCallbacks, abortSignal?: AbortSignal, ): Promise { + const startTime = Date.now(); // AI-NOTE: 2025-01-08 - Track search performance const normalizedSearchTerm = searchTerm.toLowerCase().trim(); console.log("subscription_search: Starting search:", { @@ -47,7 +59,14 @@ export async function searchBySubscription( const cachedResult = searchCache.get(searchType, normalizedSearchTerm); if (cachedResult) { console.log("subscription_search: Found cached result:", cachedResult); - return cachedResult; + // AI-NOTE: 2025-01-24 - For profile searches, return cached results immediately + // The EventSearch component now handles cache checking before calling this function + if (searchType === "n") { + console.log("subscription_search: Returning cached profile result immediately"); + return cachedResult; + } else { + return cachedResult; + } } const ndk = get(ndkInstance); @@ -64,7 +83,7 @@ export async function searchBySubscription( searchState.timeoutId = setTimeout(() => { console.log("subscription_search: Search timeout reached"); cleanup(); - }, TIMEOUTS.SUBSCRIPTION_SEARCH); + }, TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: 2025-01-24 - Use standard timeout since cache is checked first // Check for abort signal if (abortSignal?.aborted) { @@ -125,7 +144,26 @@ export async function searchBySubscription( ); searchCache.set(searchType, normalizedSearchTerm, immediateResult); - // Start Phase 2 in background for additional results + // AI-NOTE: 2025-01-08 - For profile searches, return immediately when found + // but still start background search for second-order results + if (searchType === "n") { + console.log("subscription_search: Profile found, returning immediately but starting background second-order search"); + + // Start Phase 2 in background for second-order results + searchOtherRelaysInBackground( + searchType, + searchFilter, + searchState, + callbacks, + cleanup, + ); + + const elapsed = Date.now() - startTime; + console.log(`subscription_search: Profile search completed in ${elapsed}ms`); + return immediateResult; + } + + // Start Phase 2 in background for additional results (only for non-profile searches) searchOtherRelaysInBackground( searchType, searchFilter, @@ -137,8 +175,72 @@ export async function searchBySubscription( return immediateResult; } else { console.log( - "subscription_search: No results from primary relay, continuing to Phase 2", + "subscription_search: No results from primary relay", ); + + // AI-NOTE: 2025-01-08 - For profile searches, if no results found in search relays, + // try all relays as fallback + if (searchType === "n") { + console.log( + "subscription_search: No profile found in search relays, trying all relays", + ); + // Try with all relays as fallback + const allRelaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())) as any, ndk); + try { + const fallbackEvents = await ndk.fetchEvents( + searchFilter.filter, + { closeOnEose: true }, + allRelaySet, + ); + + console.log( + "subscription_search: Fallback search returned", + fallbackEvents.size, + "events", + ); + + processPrimaryRelayResults( + fallbackEvents, + searchType, + searchFilter.subscriptionType, + normalizedSearchTerm, + searchState, + abortSignal, + cleanup, + ); + + if (hasResults(searchState, searchType)) { + console.log( + "subscription_search: Found profile in fallback search, returning immediately", + ); + const fallbackResult = createSearchResult( + searchState, + searchType, + normalizedSearchTerm, + ); + searchCache.set(searchType, normalizedSearchTerm, fallbackResult); + const elapsed = Date.now() - startTime; + console.log(`subscription_search: Profile search completed in ${elapsed}ms (fallback)`); + return fallbackResult; + } + } catch (fallbackError) { + console.error("subscription_search: Fallback search failed:", fallbackError); + } + + console.log( + "subscription_search: Profile not found in any relays, returning empty result", + ); + const emptyResult = createEmptySearchResult(searchType, normalizedSearchTerm); + // AI-NOTE: 2025-01-08 - Don't cache empty profile results as they may be due to search issues + // rather than the profile not existing + const elapsed = Date.now() - startTime; + console.log(`subscription_search: Profile search completed in ${elapsed}ms (not found)`); + return emptyResult; + } else { + console.log( + "subscription_search: No results from primary relay, continuing to Phase 2", + ); + } } } catch (error) { console.error( @@ -153,13 +255,21 @@ export async function searchBySubscription( } // Always do Phase 2: Search all other relays in parallel - return searchOtherRelaysInBackground( + const result = await searchOtherRelaysInBackground( searchType, searchFilter, searchState, callbacks, cleanup, ); + + // AI-NOTE: 2025-01-08 - Log performance for non-profile searches + if (searchType !== "n") { + const elapsed = Date.now() - startTime; + console.log(`subscription_search: ${searchType} search completed in ${elapsed}ms`); + } + + return result; } /** @@ -214,7 +324,7 @@ async function createSearchFilter( switch (searchType) { case "d": { const dFilter = { - filter: { "#d": [normalizedSearchTerm] }, + filter: { "#d": [normalizedSearchTerm], limit: SEARCH_LIMITS.GENERAL_CONTENT }, subscriptionType: "d-tag", }; console.log("subscription_search: Created d-tag filter:", dFilter); @@ -222,7 +332,7 @@ async function createSearchFilter( } case "t": { const tFilter = { - filter: { "#t": [normalizedSearchTerm] }, + filter: { "#t": [normalizedSearchTerm], limit: SEARCH_LIMITS.GENERAL_CONTENT }, subscriptionType: "t-tag", }; console.log("subscription_search: Created t-tag filter:", tFilter); @@ -253,7 +363,7 @@ async function createProfileSearchFilter( filter: { kinds: [0], authors: [decoded.data], - limit: SEARCH_LIMITS.SPECIFIC_PROFILE, + limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search }, subscriptionType: "npub-specific", }; @@ -273,7 +383,7 @@ async function createProfileSearchFilter( filter: { kinds: [0], authors: [npub], - limit: SEARCH_LIMITS.SPECIFIC_PROFILE, + limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search }, subscriptionType: "nip05-found", }; @@ -299,31 +409,38 @@ function createPrimaryRelaySet( searchType: SearchSubscriptionType, ndk: any, ): NDKRelaySet { - // Use the new relay management system - const searchRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; - console.debug('subscription_search: Active relay stores:', { - inboxRelays: get(activeInboxRelays), - outboxRelays: get(activeOutboxRelays), - searchRelays - }); - // Debug: Log all relays in NDK pool const poolRelays = Array.from(ndk.pool.relays.values()); console.debug('subscription_search: NDK pool relays:', poolRelays.map((r: any) => r.url)); if (searchType === "n") { - // For profile searches, use search relays first - const profileRelaySet = poolRelays.filter( + // AI-NOTE: 2025-01-08 - For profile searches, prioritize search relays for speed + // Use search relays first, then fall back to all relays if needed + const searchRelaySet = poolRelays.filter( (relay: any) => searchRelays.some( (searchRelay: string) => normalizeUrl(relay.url) === normalizeUrl(searchRelay), ), ); - console.debug('subscription_search: Profile relay set:', profileRelaySet.map((r: any) => r.url)); - return new NDKRelaySet(new Set(profileRelaySet) as any, ndk); + + if (searchRelaySet.length > 0) { + console.debug('subscription_search: Profile search - using search relays for speed:', searchRelaySet.map((r: any) => r.url)); + return new NDKRelaySet(new Set(searchRelaySet) as any, ndk); + } else { + // Fallback to all relays if search relays not available + console.debug('subscription_search: Profile search - fallback to all relays:', poolRelays.map((r: any) => r.url)); + return new NDKRelaySet(new Set(poolRelays) as any, ndk); + } } else { // For other searches, use active relays first + const searchRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; + console.debug('subscription_search: Active relay stores:', { + inboxRelays: get(activeInboxRelays), + outboxRelays: get(activeOutboxRelays), + searchRelays + }); + const activeRelaySet = poolRelays.filter( (relay: any) => searchRelays.some( @@ -534,11 +651,9 @@ function searchOtherRelaysInBackground( new Set( Array.from(ndk.pool.relays.values()).filter((relay: any) => { if (searchType === "n") { - // For profile searches, exclude search relays from fallback search - return !searchRelays.some( - (searchRelay: string) => - normalizeUrl(relay.url) === normalizeUrl(searchRelay), - ); + // AI-NOTE: 2025-01-08 - For profile searches, use ALL available relays + // Don't exclude any relays since we want maximum coverage + return true; } else { // For other searches, exclude community relays from fallback search return !communityRelays.some( @@ -652,6 +767,7 @@ function processProfileEoseResults( ) { const targetPubkey = dedupedProfiles[0]?.pubkey; if (targetPubkey) { + console.log("subscription_search: Triggering second-order search for npub-specific profile:", targetPubkey); performSecondOrderSearchInBackground( "n", dedupedProfiles, @@ -660,11 +776,14 @@ function processProfileEoseResults( targetPubkey, callbacks, ); + } else { + console.log("subscription_search: No targetPubkey found for second-order search"); } } 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) { + console.log("subscription_search: Triggering second-order search for general profile:", profile.pubkey); performSecondOrderSearchInBackground( "n", dedupedProfiles, @@ -675,6 +794,8 @@ function processProfileEoseResults( ); } } + } else { + console.log("subscription_search: No second-order search triggered for subscription type:", searchFilter.subscriptionType); } return { @@ -784,6 +905,7 @@ async function performSecondOrderSearchInBackground( callbacks?: SearchCallbacks, ) { try { + console.log("subscription_search: Starting second-order search for", searchType, "with targetPubkey:", targetPubkey); const ndk = get(ndkInstance); let allSecondOrderEvents: NDKEvent[] = []; @@ -797,18 +919,46 @@ async function performSecondOrderSearchInBackground( const searchPromise = (async () => { if (searchType === "n" && targetPubkey) { + console.log("subscription_search: Searching for events mentioning pubkey:", targetPubkey); + + // AI-NOTE: 2025-01-24 - Use only active relays for second-order profile search to prevent hanging + const activeRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; + const availableRelays = activeRelays + .map(url => ndk.pool.relays.get(url)) + .filter((relay): relay is any => relay !== undefined); + const relaySet = new NDKRelaySet( + new Set(availableRelays), + ndk + ); + + console.log("subscription_search: Using", activeRelays.length, "active relays for second-order search"); + // Search for events that mention this pubkey via p-tags - const pTagFilter = { "#p": [targetPubkey] }; + const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging const pTagEvents = await ndk.fetchEvents( pTagFilter, { closeOnEose: true }, - new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), + relaySet, ); - // Filter out emoji reactions - const filteredEvents = Array.from(pTagEvents).filter( - (event) => !isEmojiReaction(event), + console.log("subscription_search: Found", pTagEvents.size, "events with p-tag for", targetPubkey); + + // AI-NOTE: 2025-01-24 - Also search for events written by this pubkey with limit + const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging + const authorEvents = await ndk.fetchEvents( + authorFilter, + { closeOnEose: true }, + relaySet, ); - allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents]; + console.log("subscription_search: Found", authorEvents.size, "events written by", targetPubkey); + + // Filter out unwanted events from both sets + const filteredPTagEvents = filterUnwantedEvents(Array.from(pTagEvents)); + const filteredAuthorEvents = filterUnwantedEvents(Array.from(authorEvents)); + + console.log("subscription_search: After filtering unwanted events:", filteredPTagEvents.length, "p-tag events,", filteredAuthorEvents.length, "author events"); + + // Combine both sets of events + allSecondOrderEvents = [...filteredPTagEvents, ...filteredAuthorEvents]; } else if (searchType === "d") { // Parallel fetch for #e and #a tag events const relaySet = new NDKRelaySet( @@ -818,26 +968,22 @@ async function performSecondOrderSearchInBackground( const [eTagEvents, aTagEvents] = await Promise.all([ eventIds.size > 0 ? ndk.fetchEvents( - { "#e": Array.from(eventIds) }, + { "#e": Array.from(eventIds), limit: SEARCH_LIMITS.SECOND_ORDER_RESULTS }, { closeOnEose: true }, relaySet, ) : Promise.resolve([]), addresses.size > 0 ? ndk.fetchEvents( - { "#a": Array.from(addresses) }, + { "#a": Array.from(addresses), limit: SEARCH_LIMITS.SECOND_ORDER_RESULTS }, { closeOnEose: true }, relaySet, ) : Promise.resolve([]), ]); - // Filter out emoji reactions - const filteredETagEvents = Array.from(eTagEvents).filter( - (event) => !isEmojiReaction(event), - ); - const filteredATagEvents = Array.from(aTagEvents).filter( - (event) => !isEmojiReaction(event), - ); + // Filter out unwanted events + const filteredETagEvents = filterUnwantedEvents(Array.from(eTagEvents)); + const filteredATagEvents = filterUnwantedEvents(Array.from(aTagEvents)); allSecondOrderEvents = [ ...allSecondOrderEvents, ...filteredETagEvents, @@ -866,6 +1012,8 @@ async function performSecondOrderSearchInBackground( .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); + console.log("subscription_search: Second-order search completed with", sortedSecondOrder.length, "results"); + // Update the search results with second-order events const result: SearchResult = { events: firstOrderEvents, @@ -882,7 +1030,10 @@ async function performSecondOrderSearchInBackground( // Notify UI of updated results if (callbacks?.onSecondOrderUpdate) { + console.log("subscription_search: Calling onSecondOrderUpdate callback with", sortedSecondOrder.length, "second-order events"); callbacks.onSecondOrderUpdate(result); + } else { + console.log("subscription_search: No onSecondOrderUpdate callback available"); } })(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2fff8a9..3aae73f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,7 +5,10 @@ import { page } from "$app/stores"; import { Alert } from "flowbite-svelte"; import { HammerSolid } from "flowbite-svelte-icons"; - import { logCurrentRelayConfiguration } from "$lib/ndk"; + import { logCurrentRelayConfiguration, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; + + // Define children prop for Svelte 5 + let { children } = $props(); // Get standard metadata for OpenGraph tags let title = "Library of Alexandria"; @@ -16,12 +19,27 @@ let summary = "Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages."; + // AI-NOTE: Refactored to avoid blocking $effect with logging operations + // Reactive effect to log relay configuration when stores change - non-blocking approach + $effect.pre(() => { + const inboxRelays = $activeInboxRelays; + const outboxRelays = $activeOutboxRelays; + + // Only log if we have relays (not empty arrays) + if (inboxRelays.length > 0 || outboxRelays.length > 0) { + // Defer logging to avoid blocking the reactive system + requestAnimationFrame(() => { + console.log('🔌 Relay Configuration Updated:'); + console.log('📥 Inbox Relays:', inboxRelays); + console.log('📤 Outbox Relays:', outboxRelays); + console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); + }); + } + }); + onMount(() => { const rect = document.body.getBoundingClientRect(); // document.body.style.height = `${rect.height}px`; - - // Log relay configuration when layout mounts - logCurrentRelayConfiguration(); }); @@ -47,5 +65,5 @@
- + {@render children()}
diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index 4137220..b520ddc 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -11,7 +11,7 @@ } from "flowbite-svelte"; import { ndkInstance, ndkSignedIn, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { userStore } from "$lib/stores/userStore"; - import { communityRelays } from "$lib/consts"; + import { communityRelays, anonymousRelays } from "$lib/consts"; import type NDK from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk"; // @ts-ignore - Workaround for Svelte component import issue @@ -62,13 +62,11 @@ const repoAddress = "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr"; - // Use the new relay management system instead of hardcoded relays + // Use the new relay management system with anonymous relays as fallbacks const allRelays = [ - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://nos.lol", ...$activeInboxRelays, ...$activeOutboxRelays, + ...anonymousRelays, ]; // Hard-coded repository owner pubkey and ID from the task @@ -213,7 +211,7 @@ ...(ndk.pool ? Array.from(ndk.pool.relays.values()) .filter( - (relay) => relay.url && !relay.url.includes("wss://nos.lol"), + (relay) => relay.url, ) .map((relay) => normalizeRelayUrl(relay.url)) : []), diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte index 15c469c..fc86dc5 100644 --- a/src/routes/events/+page.svelte +++ b/src/routes/events/+page.svelte @@ -8,9 +8,10 @@ import EventDetails from "$lib/components/EventDetails.svelte"; import RelayActions from "$lib/components/RelayActions.svelte"; import CommentBox from "$lib/components/CommentBox.svelte"; +import CommentViewer from "$lib/components/CommentViewer.svelte"; import { userStore } from "$lib/stores/userStore"; import { userBadge } from "$lib/snippets/UserSnippets.svelte"; - import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils"; + import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; import EventInput from "$lib/components/EventInput.svelte"; import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte"; import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; @@ -74,6 +75,25 @@ } else { profile = null; } + + // AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author + if (newEvent.pubkey) { + cacheProfileForPubkey(newEvent.pubkey); + } + } + + // AI-NOTE: 2025-01-24 - Function to ensure profile is cached for a pubkey + async function cacheProfileForPubkey(pubkey: string) { + try { + const npub = toNpub(pubkey); + if (npub) { + // Force fetch to ensure profile is cached + await getUserMetadata(npub, true); + console.log(`[Events Page] Cached profile for pubkey: ${pubkey}`); + } + } catch (error) { + console.warn(`[Events Page] Failed to cache profile for ${pubkey}:`, error); + } } // Use Svelte 5 idiomatic effect to update searchValue when $page.url.searchParams.get('id') changes @@ -150,21 +170,27 @@ searchInProgress = loading || (results.length > 0 && secondOrder.length === 0); - // Show second-order search message when we have first-order results but no second-order yet + // AI-NOTE: 2025-01-08 - Only show second-order search message if we're actually searching + // Don't show it for cached results that have no second-order events if ( results.length > 0 && secondOrder.length === 0 && - searchTypeParam === "n" + searchTypeParam === "n" && + !loading // Only show message if we're actively searching, not for cached results ) { 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" + searchTypeParam === "d" && + !loading // Only show message if we're actively searching, not for cached results ) { secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`; } else if (secondOrder.length > 0) { secondOrderSearchMessage = null; + } else { + // Clear message if we have results but no second-order search is happening + secondOrderSearchMessage = null; } // Check community status for all search results @@ -178,11 +204,32 @@ checkCommunityStatusForResults(tTagEvents); } + // AI-NOTE: 2025-01-24 - Cache profiles for all search results + cacheProfilesForEvents([...results, ...secondOrder, ...tTagEvents]); + // Don't clear the current event - let the user continue viewing it // event = null; // profile = null; } + // AI-NOTE: 2025-01-24 - Function to cache profiles for multiple events + async function cacheProfilesForEvents(events: NDKEvent[]) { + const uniquePubkeys = new Set(); + events.forEach(event => { + if (event.pubkey) { + uniquePubkeys.add(event.pubkey); + } + }); + + console.log(`[Events Page] Caching profiles for ${uniquePubkeys.size} unique pubkeys`); + + // Cache profiles in parallel + const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey)); + await Promise.allSettled(cachePromises); + + console.log(`[Events Page] Profile caching complete`); + } + function handleClear() { searchType = null; searchTerm = null; @@ -226,48 +273,47 @@ originalEventIds: Set, originalAddresses: Set, ): string { - // Check if this event has e-tags referencing original events - const eTags = getMatchingTags(event, "e"); - for (const tag of eTags) { - if (originalEventIds.has(tag[1])) { - return "Reply/Reference (e-tag)"; - } - } + const eTags = event.getMatchingTags("e"); + const aTags = event.getMatchingTags("a"); - // Check if this event has a-tags or e-tags referencing original events - let tags = getMatchingTags(event, "a"); - if (tags.length === 0) { - tags = getMatchingTags(event, "e"); + if (eTags.length > 0) { + const referencedEventId = eTags[eTags.length - 1][1]; + if (originalEventIds.has(referencedEventId)) { + return "Reply"; + } } - for (const tag of tags) { - if (originalAddresses.has(tag[1])) { - return "Reply/Reference (a-tag)"; + if (aTags.length > 0) { + const referencedAddress = aTags[aTags.length - 1][1]; + if (originalAddresses.has(referencedAddress)) { + return "Quote"; } } - // Check if this event has content references - if (event.content) { - for (const id of originalEventIds) { - const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, "i"); - const notePattern = new RegExp(`note1[a-z0-9]{50,}`, "i"); - if ( - neventPattern.test(event.content) || - notePattern.test(event.content) - ) { - return "Content Reference"; - } - } + return "Reference"; + } - for (const address of originalAddresses) { - const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, "i"); - if (naddrPattern.test(event.content)) { - return "Content Reference"; - } - } + // AI-NOTE: 2025-01-24 - Function to parse profile content from kind 0 events + function parseProfileContent(event: NDKEvent): { + name?: string; + display_name?: string; + about?: string; + picture?: string; + banner?: string; + website?: string; + lud16?: string; + nip05?: string; + } | null { + if (event.kind !== 0 || !event.content) { + return null; } - return "Reference"; + try { + return JSON.parse(event.content); + } catch (error) { + console.warn("Failed to parse profile content:", error); + return null; + } } function getNeventUrl(event: NDKEvent): string { @@ -346,9 +392,22 @@ - // Log relay configuration when page mounts - onMount(() => { - logCurrentRelayConfiguration(); + // AI-NOTE: Refactored to avoid blocking $effect with logging operations + // Reactive effect to log relay configuration when stores change - non-blocking approach + $effect.pre(() => { + const inboxRelays = $activeInboxRelays; + const outboxRelays = $activeOutboxRelays; + + // Only log if we have relays (not empty arrays) + if (inboxRelays.length > 0 || outboxRelays.length > 0) { + // Defer logging to avoid blocking the reactive system + requestAnimationFrame(() => { + console.log('🔌 Events Page - Relay Configuration Updated:'); + console.log('📥 Inbox Relays:', inboxRelays); + console.log('📤 Outbox Relays:', outboxRelays); + console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); + }); + } }); @@ -398,19 +457,22 @@ {#if searchResults.length > 0}
- + {#if searchType === "n"} - Search Results for name: "{searchTerm}" ({searchResults.length} profiles) + Search Results for name: "{searchTerm && searchTerm.length > 50 ? searchTerm.slice(0, 50) + '...' : searchTerm || ''}" ({searchResults.length} profiles) {:else if searchType === "t"} - Search Results for t-tag: "{searchTerm}" ({searchResults.length} + Search Results for t-tag: "{searchTerm && searchTerm.length > 50 ? searchTerm.slice(0, 50) + '...' : searchTerm || ''}" ({searchResults.length} events) {:else} - Search Results for d-tag: "{searchTerm || - dTagValue?.toLowerCase()}" ({searchResults.length} events) + Search Results for d-tag: "{(() => { + const term = searchTerm || dTagValue?.toLowerCase() || ''; + return term.length > 50 ? term.slice(0, 50) + '...' : term; + })()}" ({searchResults.length} events) {/if}
{#each searchResults as result, index} + {@const profileData = parseProfileContent(result)}
- {#if getSummary(result)} -
- {getSummary(result)} + {#if result.kind === 0 && profileData} +
+ {#if profileData.picture} + Profile { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} +
+ + {(profileData.display_name || profileData.name || result.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} +
+ {#if profileData.display_name || profileData.name} + + {profileData.display_name || profileData.name} + + {/if} + {#if profileData.about} + + {profileData.about} + + {/if} +
- {/if} - {#if getDeferralNaddr(result)} -
- Read - { - e.stopPropagation(); - navigateToPublication( - getDeferralNaddr(result) || "", - ); - }} - onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + {:else} + {#if getSummary(result)} +
+ {getSummary(result)} +
+ {/if} + {#if getDeferralNaddr(result)} +
+ Read + { e.stopPropagation(); navigateToPublication( getDeferralNaddr(result) || "", ); - } - }} - tabindex="0" - role="button" + }} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + navigateToPublication( + getDeferralNaddr(result) || "", + ); + } + }} + tabindex="0" + role="button" + > + {getDeferralNaddr(result)} + +
+ {/if} + {#if isAddressableEvent(result)} +
- {getDeferralNaddr(result)} - -
- {/if} - {#if isAddressableEvent(result)} -
- -
- {/if} - {#if result.content} -
- {result.content.slice(0, 200)}{result.content.length > - 200 - ? "..." - : ""} -
+ +
+ {/if} + {#if result.content} +
+ {result.content.slice(0, 200)}{result.content.length > + 200 + ? "..." + : ""} +
+ {/if} {/if}
@@ -535,6 +630,7 @@

{#each secondOrderResults as result, index} + {@const profileData = parseProfileContent(result)} @@ -659,6 +788,7 @@

{#each tTagResults as result, index} + {@const profileData = parseProfileContent(result)}
- {#if getSummary(result)} -
- {getSummary(result)} + {#if result.kind === 0 && profileData} +
+ {#if profileData.picture} + Profile { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} +
+ + {(profileData.display_name || profileData.name || result.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} +
+ {#if profileData.display_name || profileData.name} + + {profileData.display_name || profileData.name} + + {/if} + {#if profileData.about} + + {profileData.about} + + {/if} +
- {/if} - {#if getDeferralNaddr(result)} -
- Read - { - e.stopPropagation(); - navigateToPublication( - getDeferralNaddr(result) || "", - ); - }} - onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + {:else} + {#if getSummary(result)} +
+ {getSummary(result)} +
+ {/if} + {#if getDeferralNaddr(result)} +
+ Read + { e.stopPropagation(); navigateToPublication( getDeferralNaddr(result) || "", ); - } - }} - tabindex="0" - role="button" + }} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + navigateToPublication( + getDeferralNaddr(result) || "", + ); + } + }} + tabindex="0" + role="button" + > + {getDeferralNaddr(result)} + +
+ {/if} + {#if isAddressableEvent(result)} +
- {getDeferralNaddr(result)} - -
- {/if} - {#if isAddressableEvent(result)} -
- -
- {/if} - {#if result.content} -
- {result.content.slice(0, 200)}{result.content.length > - 200 - ? "..." - : ""} -
+ +
+ {/if} + {#if result.content} +
+ {result.content.slice(0, 200)}{result.content.length > + 200 + ? "..." + : ""} +
+ {/if} {/if}
@@ -810,6 +973,8 @@ + + {#if isLoggedIn && userPubkey}
Add Comment