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 688bacd..65cd830 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/data_structures/docs/relay_selector_design.md b/src/lib/data_structures/docs/relay_selector_design.md new file mode 100644 index 0000000..0fb1616 --- /dev/null +++ b/src/lib/data_structures/docs/relay_selector_design.md @@ -0,0 +1,199 @@ +# Relay Selector Class Design + +The relay selector will be a singleton that tracks, rates, and ranks Nostr relays to help the application determine which relay should be used to handle each request. It will weight relays based on observed characteristics, then use these weights to implement a weighted round robin algorithm for selecting relays, with some additional modifications to account for domain-specific features of Nostr. + +## Relay Weights + +### Categories + +Relays are broadly divided into three categories: + +1. **Public**: no authorization is required +2. **Private Write**: authorization is required to write to this relay, but not to read +3. **Private Read and Write**: authorization is required to use any features of this relay + +The broadest level of relay selection is based on these categories. + +- For users that are not logged in, public relays are used exclusively. +- For logged-in users, public and private read relays are initially rated equally for read operations. +- For logged-in users, private write relays are preferred above public relays for write operations. + +### User Preferences + +The relay selector will respect user relay preferences while still attempting to optimize for responsiveness and success rate. + +- User inbox relays will be stored in a separate list from general-purpose relays, and weighted and sorted separately using the same algorithm as the general-purpose relay list. +- Local relays (beginning with `wss://localhost` or `ws://localhost`) will be stored _unranked_ in a separate list, and used when the relay selector is operating on a web browser (as opposed to a server). +- When a caller requests relays from the relay selector, the selector will return: + - The highest-ranked general-purpose relay + - The highest-ranked user inbox relay + - (If on browser) any local relays + +### Weighted Metrics + +Several weighted metrics are used to compute a relay's score. The score is used to rank relays to determine which to prefer when fetching events. + +#### Response Time + +The response time weight of each relay is computed according to the logarithmic function $`r(t) = -log(t) + 1`$, where $`t`$ is the median response time in seconds. This function has a few features which make it useful: + +- $`r(1) = 1`$, making a response time of 1s the netural point. This causes the algorithm to prefer relays that respond in under 1s. +- $`r(0.3) \approx 1.5`$ and $`r(3) \approx 0.5`$. This clusters the 0.5 to 1.5 weight range in the 300ms to 3s response time range, which is a sufficiently rapid response time to keep user's from switching context. +- The function has a long tail, so it doesn't discount slower response times too heavily, too quickly. + +#### Success Rate + +The success rate $`s(x)`$ is computed as the fraction of total requests sent to the relay that returned at least one event in response. The optimal score is 1, meaning the relay successfully responds to 100% of requests. + +#### Trust Level + +Certain relays may be assigned a constant "trust level" score $`T`$. This modifier is a number in the range $`[-0.5, 0.5]`$ that indicates how much a relay is trusted by the GitCitadel organization. + +A few factors contribute to a higher trust rating: + +- Effective filtering of spam and abusive content. +- Good data transparency, including such policies as honoring deletion requests. +- Event aggregation policies that aim at synchronization with the broader relay network. + +#### Preferred Vendors + +Certain relays may be assigned a constant "preferred vendor" score $`V`$. This modifier is a number in the range $`[0, 0.5]`$. It is used to increase the priority of GitCitadel's preferred relay vendors. + +### Overall Weight + +The overall weight of a relay is calculated as $`w(t, x) = r(t) \times s(x) + T + V`$. The `RelaySelector` class maintains a list of relays sorted by their overall weights. The weights may be updated at runtime when $`t`$ or $`x`$ change. On update, the relay list is re-sorted to account for the new weights. + +## Algorithm + +The relay weights contribute to a weighted round robin (WRR) algorithm for relay selection. Pseudocode for the algorithm is given below: + +```pseudocode +Constants and Variables: + const N // Number of relays + const CW // Connection weight + wInit // Map of relay URLs to initial weights + conn // Map of relay URLs to the number of active connections to that relay + wCurr // Current relay weights + rSorted // List of relay URLs sorted in ascending order + +Function getRelay: + r = rSorted[N - 1] // Get the highest-ranked relay + conn[r]++ // Increment the number of connections + wCurr[r] = wInit[r] + conn[r] * CW // Adjust current weights based on new connection weight + sort rSorted by wCurr // Re-sort based on updated weights + return r +``` + +## Class Methods + +The `RelaySelector` class should expose the following methods to support updates to relay weights. Pseudocode for each method is given below. + +### Add Response Time Datum + +This function updates the class state by side effect. Locking should be used in concurrent use cases. + +```pseudocode +Constants and Variables: + const CW // Connection weight + rT // A map of relay URLs to their Trust Level scores + rV // A map of relay URLs to their Preferred Vendor scores + rTimes // A map of relay URLs to a list or recorded response times + rReqs // A map of relay URLs to the number of recorded requests + rSucc // A map of relay URLs to the number of successful requests + rTimes // A map of relay URLs to recorded response times + wInit // Map of relay URLs to initial weights + conn // Map of relay URLs to the number of active connections to that relay + wCurr // Current relay weights + rSorted // List of relay URLs sorted in ascending order + +Parameters: + r // A relay URL + rt // A response time datum recorded for the given relay + +Function addResponseTimeDatum: + append rt to rTimes[r] + sort rTimes[r] + rtMed = median of rTimes[r] + rtWeight = -1 * log(rtMed) + 1 + succRate = rSucc[r] / rReqs[r] + wInit[r] = rtWeight * succRate + rT[r] + rV[r] + wCurr[r] = wInit[r] + conn[r] * CW + sort rSorted by wCurr +``` + +### Add Success Rate Datum + +This function updates the class state by side effect. Locking should be used in concurrent use cases. + +```pseudocode +Constants and Variables: + const CW // Connection weight + rT // A map of relay URLs to their Trust Level scores + rV // A map of relay URLs to their Preferred Vendor scores + rReqs // A map of relay URLs to the number of recorded requests + rSucc // A map of relay URLs to the number of successful requests + rTimes // A map of relay URLs to recorded response times + wInit // Map of relay URLs to initial weights + conn // Map of relay URLs to the number of active connections to that relay + wCurr // Current relay weights + rSorted // List of relay URLs sorted in ascending order + +Parameters: + r // A relay URL + s // A boolean value indicating whether the latest request to relay r succeeded + +Function addSuccessRateDatum: + rReqs[r]++ + if s is true: + rSucc[r]++ + rtMed = median of rTimes[r] + rtWeight = -1 * log(rtMed) + 1 + succRate = rSuccReqs[r] / rReqs[r] + wInit[r] = rtWeight * succRate + rT[r] + rV[r] + wCurr[r] = wInit[r] + conn[r] * CW + sort rSorted by wCurr +``` + +### Add Relay + +```pseudocode +Constants and Variables: + general // A list of general-purpose relay URLs + inbox // A list of user-defined inbox relay URLs + local // A list of local relay URLs + +Parameters: + r // The relay URL + rType // The relay type (general, inbox, or local) + +Function addRelay: + if rType is "general": + add r to general + sort general by current weights + if rType is "inbox": + add r to inbox + sort inbox by current weights + if rType is "local": + add r to local +``` + +### Get Relay + +``` +Constants and Variables: + general // A sorted list of general-purpose relay URLs + inbox // A sorted list of user-defined inbox relay URLs + local // An unsorted list of local relay URLs + +Parameters: + rank // The requested rank + +Function getRelay: + selected = [] + if local has members: + add all local members to selected + if rank less than length of inbox: + add inbox[rank] to selected + if rank less than length of general: + add general[rank] to selected +``` 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