diff --git a/.cursor/rules/alexandria.mdc b/.cursor/rules/alexandria.mdc index 15ee6d0..d62d36e 100644 --- a/.cursor/rules/alexandria.mdc +++ b/.cursor/rules/alexandria.mdc @@ -42,10 +42,10 @@ NEVER assume developer intent. If you are unsure about something, ALWAYS stop an - Use anchor comments prefixed with `AI-NOTE:`, `AI-TODO:`, or `AI-QUESTION:` to share context between AI agents and developers across time. - Use all-caps prefixes. - - Also _read_ (but do not write) variants of this format that begin with `AI-:` where `` is some date in `MM/DD/YYYY` format. Anchor comments with this format are used by developers to record context. - **Important:** Before scanning files, ALWAYS search first for `AI-` anchor comments in relevant subdirectories. - ALWAYS update relevant anchor comments when modifying associated code. - NEVER remove `AI-` comments unless the developer explicitly instructs it. +- Don't add a date, as it's always the wrong date. - Add new anchor comments as relevant when: - Code is unusually complex. - Code is critical to security, performance, or functionality. diff --git a/src/app.css b/src/app.css index c15dc2d..aa88636 100644 --- a/src/app.css +++ b/src/app.css @@ -98,6 +98,15 @@ @apply text-gray-900 dark:text-gray-100; } + /* Responsive card styles */ + .responsive-card { + @apply w-full min-w-0 overflow-hidden; + } + + .responsive-card-content { + @apply break-words overflow-hidden; + } + h1.h-leather { @apply text-4xl font-bold; } @@ -588,4 +597,24 @@ .prose-invert p:first-line { font-weight: normal !important; } + + /* Prevent first-line indentation in prose content */ + .prose p, + .prose-sm p, + .prose-invert p { + text-indent: 0 !important; + } + + /* Ensure embedded event content doesn't have unwanted indentation */ + .embedded-event .prose p, + .embedded-event .prose-sm p, + .embedded-event .prose-invert p { + text-indent: 0 !important; + margin: 0 !important; + } + + /* Prevent indentation for paragraphs with no-indent class */ + .no-indent { + text-indent: 0 !important; + } } diff --git a/src/lib/components/CommentBox.svelte b/src/lib/components/CommentBox.svelte index 5a7396e..2c9d43d 100644 --- a/src/lib/components/CommentBox.svelte +++ b/src/lib/components/CommentBox.svelte @@ -1,16 +1,10 @@
- {#if event.kind !== 0 && getEventTitle(event)} + {#if event.kind !== 0}

- {getEventTitle(event)} + {@render basicMarkup(getEventTitle(event), ndk)}

{/if} @@ -276,6 +312,7 @@ >Author: {@render userBadge( toNpub(event.pubkey) as string, profile?.display_name || undefined, + ndk, )} {:else} @@ -293,30 +330,70 @@ >
- {#if getEventSummary(event)} -
- Summary: -

{getEventSummary(event)}

+
+ Summary: +
+ {@render basicMarkup(getEventSummary(event), ndk)}
- {/if} +
{#if event.kind !== 0} + {@const kind = event.kind} + {@const content = event.content.trim()}
Content:
-
- -
- {#if shouldTruncate} - + {#if isRepost} + + {#if repostKinds.includes(event.kind)} + +
+
+ {event.kind === 6 ? 'Reposted content:' : 'Generic reposted content:'} +
+ {@render repostContent(event.content)} +
+ {:else if event.kind === 1 && event.getMatchingTags("q").length > 0} + +
+
+ Quote repost: +
+ {@render quotedContent(event, [], ndk)} + {#if content} +
+
+ Added comment: +
+ {#if repostKinds.includes(kind)} + {@html content} + {:else} + {@render basicMarkup(content, ndk)} + {/if} +
+ {/if} +
+ {/if} + {:else} + +
+ {#if repostKinds.includes(kind)} + {@html content} + {:else} + {@render basicMarkup(content, ndk)} + {/if} +
+ {#if shouldTruncate} + + {/if} {/if}
@@ -328,6 +405,7 @@ {/if} @@ -404,7 +482,14 @@ const tTag = tagInfo.gotoValue!.substring(2); goto(`/events?t=${encodeURIComponent(tTag)}`); } else if (/^[0-9a-fA-F]{64}$/.test(tagInfo.gotoValue!)) { - // For hex event IDs - use navigateToEvent + // AI-NOTE: E-tag navigation may cause comment feed update issues + // When navigating to a new event via e-tag, the CommentViewer component + // may experience timing issues that result in: + // - Empty comment feeds even when comments exist + // - UI flashing between different thread views + // - Delayed comment loading + // This is likely due to race conditions between event prop changes + // and comment fetching in the CommentViewer component. navigateToEvent(tagInfo.gotoValue!); } else { // For other cases, try direct navigation diff --git a/src/lib/components/EventInput.svelte b/src/lib/components/EventInput.svelte index e4cdc48..e33d483 100644 --- a/src/lib/components/EventInput.svelte +++ b/src/lib/components/EventInput.svelte @@ -10,7 +10,7 @@ import { publishEvent, loadEvent } from "./event_input/eventServices"; import { getNdkContext } from "$lib/ndk"; - // AI-NOTE: 2025-01-24 - Main EventInput component refactored for better separation of concerns + // AI-NOTE: Main EventInput component refactored for better separation of concerns // This component now serves as a container that orchestrates the form, tags, preview, and publishing // Get NDK context at component level (can only be called during initialization) diff --git a/src/lib/components/EventSearch.svelte b/src/lib/components/EventSearch.svelte index 69ac62e..85f4bdc 100644 --- a/src/lib/components/EventSearch.svelte +++ b/src/lib/components/EventSearch.svelte @@ -44,7 +44,7 @@ addresses: Set, searchType?: string, searchTerm?: string, - loading?: boolean, // AI-NOTE: 2025-01-24 - Add loading parameter for second-order search message logic + loading?: boolean, // AI-NOTE: Add loading parameter for second-order search message logic ) => void; event: NDKEvent | null; onClear?: () => void; @@ -399,7 +399,7 @@ } } - // AI-NOTE: 2025-01-24 - Effects organized for better readability + // AI-NOTE: Effects organized for better readability $effect(() => { if (searching || isResetting || isUserEditing) { return; @@ -507,7 +507,7 @@ } }); - // AI-NOTE: 2025-01-24 - Utility functions for event matching and state management + // AI-NOTE: Utility functions for event matching and state management function isCurrentEventMatch(searchValue: string, event: NDKEvent): boolean { const currentEventId = event.id; let currentNaddr: string | null = null; @@ -810,7 +810,7 @@ } } - // AI-NOTE: 2025-01-24 - Background profile search is now handled by centralized searchProfiles function + // AI-NOTE: Background profile search is now handled by centralized searchProfiles function // This function is no longer needed as profile searches go through subscription_search.ts // which delegates to the centralized profile_search.ts diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 7e1a5bd..37e210a 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -17,12 +17,14 @@ getNotificationType, fetchAuthorProfiles, quotedContent, - } from "$lib/components/embedded_events/EmbeddedSnippets.svelte"; + } from "$lib/snippets/EmbeddedSnippets.svelte"; import { buildCompleteRelaySet } from "$lib/utils/relay_management"; import { formatDate, neventEncode } from "$lib/utils"; import { NDKRelaySetFromNDK } from "$lib/utils/nostrUtils"; - import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte"; + import { repostContent } from "$lib/snippets/EmbeddedSnippets.svelte"; + import { repostKinds } from "$lib/consts"; import { getNdkContext } from "$lib/ndk"; + import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; const { event } = $props<{ event: NDKEvent }>(); @@ -54,6 +56,16 @@ let authorProfiles = $state>(new Map()); let filteredByUser = $state(null); + // AI-NOTE: Client-side pagination - fetch once, paginate locally + let allToMeNotifications = $state([]); // All fetched "to-me" notifications + let allFromMeNotifications = $state([]); // All fetched "from-me" notifications + let allPublicMessages = $state([]); // All fetched public messages + let currentPage = $state(1); + let itemsPerPage = 20; // Show 20 items per page + let hasFetchedToMe = $state(false); // Track if we've already fetched "to-me" data + let hasFetchedFromMe = $state(false); // Track if we've already fetched "from-me" data + let hasFetchedPublic = $state(false); // Track if we've already fetched public messages + // New Message Modal state let showNewMessageModal = $state(false); let newMessageContent = $state(""); @@ -459,10 +471,52 @@ } } - // AI-NOTE: Simplified notification fetching + // AI-NOTE: Client-side pagination calculations + let paginatedNotifications = $derived.by(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const currentNotifications = notificationMode === "to-me" ? allToMeNotifications : allFromMeNotifications; + return currentNotifications.slice(startIndex, endIndex); + }); + + let paginatedPublicMessages = $derived.by(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return allPublicMessages.slice(startIndex, endIndex); + }); + + let totalPages = $derived.by(() => { + let totalItems = 0; + if (notificationMode === "public-messages") { + totalItems = allPublicMessages.length; + } else if (notificationMode === "to-me") { + totalItems = allToMeNotifications.length; + } else { + totalItems = allFromMeNotifications.length; + } + return Math.ceil(totalItems / itemsPerPage); + }); + + let hasNextPage = $derived.by(() => currentPage < totalPages); + let hasPreviousPage = $derived.by(() => currentPage > 1); + + // AI-NOTE: Optimized notification fetching - fetch once, paginate locally async function fetchNotifications() { - if (!$userStore.pubkey || !isOwnProfile) return; + if (!$userStore.pubkey || !isOwnProfile || isFetching) return; + // Check if we've already fetched data for this specific mode + if (notificationMode === "to-me" && hasFetchedToMe && allToMeNotifications.length > 0) { + currentPage = 1; + notifications = paginatedNotifications; + return; + } + if (notificationMode === "from-me" && hasFetchedFromMe && allFromMeNotifications.length > 0) { + currentPage = 1; + notifications = paginatedNotifications; + return; + } + + isFetching = true; loading = true; error = null; @@ -481,7 +535,7 @@ ? { "#p": [$userStore.pubkey] } : { authors: [$userStore.pubkey] } ), - limit: 100, + limit: 500, // Fetch more data once to avoid multiple relay calls }; const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); @@ -499,22 +553,41 @@ } }); - notifications = filteredEvents - .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) - .slice(0, 100); + const sortedEvents = filteredEvents + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + + // Store in the appropriate array based on mode + if (notificationMode === "to-me") { + allToMeNotifications = sortedEvents; + hasFetchedToMe = true; + } else { + allFromMeNotifications = sortedEvents; + hasFetchedFromMe = true; + } - authorProfiles = await fetchAuthorProfiles(notifications, ndk); + // Set current page to 1 and update displayed notifications + currentPage = 1; + notifications = paginatedNotifications; + + // Load profiles in background + authorProfiles = await fetchAuthorProfiles(sortedEvents, ndk); } catch (err) { console.error("[Notifications] Error fetching notifications:", err); error = err instanceof Error ? err.message : "Failed to fetch notifications"; } finally { loading = false; + isFetching = false; } } - // AI-NOTE: Simplified public messages fetching - only kind 24 messages + // AI-NOTE: Optimized public messages fetching - fetch once, paginate locally async function fetchPublicMessages() { - if (!$userStore.pubkey || !isOwnProfile) return; + if (!$userStore.pubkey || !isOwnProfile || isFetching) return; + + // Only fetch if we haven't already fetched data for this mode + if (hasFetchedPublic && allPublicMessages.length > 0) { + return; + } loading = true; error = null; @@ -524,6 +597,9 @@ const userStoreValue = get(userStore); const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; + + // AI-NOTE: Cache relay set to prevent excessive calls + console.log("[PublicMessages] Building relay set for public messages..."); const relaySet = await buildCompleteRelaySet(ndk, user); const relays = [...relaySet.inboxRelays, ...relaySet.outboxRelays]; if (relays.length === 0) throw new Error("No relays available"); @@ -532,8 +608,8 @@ // Fetch only kind 24 messages const [messagesEvents, userMessagesEvents] = await Promise.all([ - ndk.fetchEvents({ kinds: [24 as any], "#p": [$userStore.pubkey], limit: 200 }, undefined, ndkRelaySet), - ndk.fetchEvents({ kinds: [24 as any], authors: [$userStore.pubkey], limit: 200 }, undefined, ndkRelaySet) + ndk.fetchEvents({ kinds: [24 as any], "#p": [$userStore.pubkey], limit: 500 }, undefined, ndkRelaySet), + ndk.fetchEvents({ kinds: [24 as any], authors: [$userStore.pubkey], limit: 500 }, undefined, ndkRelaySet) ]); const allMessages = [ @@ -541,24 +617,75 @@ ...Array.from(userMessagesEvents) ]; - // Deduplicate and filter + // Deduplicate and sort const uniqueMessages = allMessages.filter((event, index, self) => index === self.findIndex(e => e.id === event.id) ); - publicMessages = uniqueMessages - .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) - .slice(0, 200); + allPublicMessages = uniqueMessages + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); - authorProfiles = await fetchAuthorProfiles(publicMessages, ndk); + // Set current page to 1 and update displayed messages + currentPage = 1; + publicMessages = paginatedPublicMessages; + hasFetchedPublic = true; + + // Load profiles in background + authorProfiles = await fetchAuthorProfiles(allPublicMessages, ndk); } catch (err) { console.error("[PublicMessages] Error fetching public messages:", err); error = err instanceof Error ? err.message : "Failed to fetch public messages"; } finally { loading = false; + isFetching = false; + } + } + + // AI-NOTE: Pagination navigation functions + function nextPage() { + if (hasNextPage) { + currentPage++; + updateDisplayedItems(); + } + } + + function previousPage() { + if (hasPreviousPage) { + currentPage--; + updateDisplayedItems(); + } + } + + function goToPage(page: number) { + if (page >= 1 && page <= totalPages) { + currentPage = page; + updateDisplayedItems(); + } + } + + // AI-NOTE: Update displayed items based on current page + function updateDisplayedItems() { + if (notificationMode === "public-messages") { + publicMessages = paginatedPublicMessages; + } else { + notifications = paginatedNotifications; } } + // AI-NOTE: Reset pagination when mode changes + function resetPagination() { + currentPage = 1; + hasFetchedToMe = false; + hasFetchedFromMe = false; + hasFetchedPublic = false; + allToMeNotifications = []; + allFromMeNotifications = []; + allPublicMessages = []; + notifications = []; + publicMessages = []; + authorProfiles.clear(); + } + // Check if user is viewing their own profile $effect(() => { if ($userStore.signedIn && $userStore.pubkey && event.pubkey) { @@ -568,22 +695,64 @@ } }); - // Fetch notifications when viewing own profile or when mode changes + // AI-NOTE: Track previous state to prevent unnecessary refetches + let previousMode = $state<"to-me" | "from-me" | "public-messages" | null>(null); + let previousPubkey = $state(null); + let previousIsOwnProfile = $state(false); + let isFetching = $state(false); // Guard against concurrent fetches + + // Fetch notifications when viewing own profile or when mode changes - with guards $effect(() => { - if (isOwnProfile && $userStore.pubkey && $userStore.signedIn) { - if (notificationMode === "public-messages") { - fetchPublicMessages(); - } else { - fetchNotifications(); + const currentMode = notificationMode; + const currentPubkey = $userStore.pubkey; + const currentIsOwnProfile = isOwnProfile; + + // Only proceed if something actually changed and we're not already fetching + if (currentIsOwnProfile && currentPubkey && $userStore.signedIn && !isFetching) { + if (previousMode !== currentMode || previousPubkey !== currentPubkey || previousIsOwnProfile !== currentIsOwnProfile) { + console.log("[Notifications] Mode or user changed, fetching data..."); + + // Reset pagination when mode changes + if (currentMode === "public-messages" && !hasFetchedPublic) { + resetPagination(); + fetchPublicMessages(); + } else if (currentMode !== "public-messages" && + ((currentMode === "to-me" && !hasFetchedToMe) || + (currentMode === "from-me" && !hasFetchedFromMe))) { + resetPagination(); + fetchNotifications(); + } else { + // Mode changed but we have data - just update displayed items + currentPage = 1; + updateDisplayedItems(); + } + + // Update previous state + previousMode = currentMode; + previousPubkey = currentPubkey; + previousIsOwnProfile = currentIsOwnProfile; } - } else { + } else if ((previousIsOwnProfile || previousPubkey) && !currentIsOwnProfile) { // Clear notifications when user logs out or is not viewing own profile - notifications = []; - publicMessages = []; - authorProfiles.clear(); + console.log("[Notifications] User logged out, clearing data..."); + resetPagination(); + previousMode = null; + previousPubkey = null; + previousIsOwnProfile = false; } }); + // AI-NOTE: Update displayed items when page changes - debounced + let pageUpdateTimeout: ReturnType | null = null; + $effect(() => { + if (pageUpdateTimeout) { + clearTimeout(pageUpdateTimeout); + } + pageUpdateTimeout = setTimeout(() => { + updateDisplayedItems(); + }, 50); + }); + // AI-NOTE: Refactored to avoid blocking $effect with async operations // Calculate relay set when recipients change - non-blocking approach $effect(() => { @@ -678,7 +847,6 @@ onclick={() => openNewMessageModal()} class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium" > - New Message @@ -817,7 +985,36 @@ {#if message.content}
- +
+ {#if repostKinds.includes(message.kind)} + +
+
+ {message.kind === 6 ? 'Repost:' : 'Generic repost:'} +
+ {@render repostContent(message.content)} +
+ {:else if message.kind === 1 && message.getMatchingTags("q").length > 0} + +
+
+ Quote repost: +
+ {@render quotedContent(message, publicMessages, ndk)} + {#if message.content && message.content.trim()} +
+
+ Comment: +
+ {@render basicMarkup(message.content, ndk)} +
+ {/if} +
+ {:else} + + {@render basicMarkup(message.content || "No content", ndk)} + {/if} +
{/if} @@ -830,9 +1027,31 @@ {/each}
- {#if filteredMessages.length > 100} -
- Showing 100 of {filteredMessages.length} messages {filteredByUser ? `(filtered)` : ''}. Scroll to see more. + + {#if totalPages > 1} +
+
+ Page {currentPage} of {totalPages} ({allPublicMessages.length} total messages) +
+
+ + + {currentPage} / {totalPages} + + +
{/if}
@@ -894,7 +1113,36 @@ {#if notification.content}
- +
+ {#if repostKinds.includes(notification.kind)} + +
+
+ {notification.kind === 6 ? 'Repost:' : 'Generic repost:'} +
+ {@render repostContent(notification.content)} +
+ {:else if notification.kind === 1 && notification.getMatchingTags("q").length > 0} + +
+
+ Quote repost: +
+ {@render quotedContent(notification, notifications, ndk)} + {#if notification.content && notification.content.trim()} +
+
+ Comment: +
+ {@render basicMarkup(notification.content, ndk)} +
+ {/if} +
+ {:else} + + {@render basicMarkup(notification.content || "No content", ndk)} + {/if} +
{/if} @@ -905,9 +1153,31 @@
{/each} - {#if notifications.length > 100} -
- Showing 100 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more. + + {#if totalPages > 1} +
+
+ Page {currentPage} of {totalPages} ({notificationMode === "to-me" ? allToMeNotifications.length : allFromMeNotifications.length} total notifications) +
+
+ + + {currentPage} / {totalPages} + + +
{/if}
@@ -929,7 +1199,9 @@
Replying to:
- +
+ {@render basicMarkup(replyToMessage.content || "No content", ndk)} +
{/if} diff --git a/src/lib/components/cards/BlogHeader.svelte b/src/lib/components/cards/BlogHeader.svelte index ff218fe..3df794a 100644 --- a/src/lib/components/cards/BlogHeader.svelte +++ b/src/lib/components/cards/BlogHeader.svelte @@ -9,7 +9,8 @@ import { getMatchingTags } from "$lib/utils/nostrUtils"; import LazyImage from "$components/util/LazyImage.svelte"; import { generateDarkPastelColor } from "$lib/utils/image_utils"; - + import { getNdkContext } from "$lib/ndk"; + const { rootId, event, @@ -22,6 +23,8 @@ active: boolean; }>(); + const ndk = getNdkContext(); + let title: string = $derived(event.getMatchingTags("title")[0]?.[1]); let author: string = $derived( getMatchingTags(event, "author")[0]?.[1] ?? "unknown", @@ -59,7 +62,7 @@
- {@render userBadge(authorPubkey, author)} + {@render userBadge(authorPubkey, author, ndk)} {publishedAt()}
diff --git a/src/lib/components/cards/ProfileHeader.svelte b/src/lib/components/cards/ProfileHeader.svelte index 7973172..1eeb89f 100644 --- a/src/lib/components/cards/ProfileHeader.svelte +++ b/src/lib/components/cards/ProfileHeader.svelte @@ -17,6 +17,8 @@ import { goto } from "$app/navigation"; import { isPubkeyInUserLists, fetchCurrentUserLists } from "$lib/utils/user_lists"; import { UserOutline } from "flowbite-svelte-icons"; + import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; + import { getNdkContext } from "$lib/ndk"; const { event, @@ -30,6 +32,8 @@ communityStatusMap?: Record; }>(); + const ndk = getNdkContext(); + let lnModalOpen = $state(false); let lnurl = $state(null); let communityStatus = $state(null); @@ -51,26 +55,32 @@ $effect(() => { if (event?.pubkey) { - // First check if we have cached profileData with user list information - const cachedProfileData = (event as any).profileData; - console.log(`[ProfileHeader] Checking user list status for ${event.pubkey}, cached profileData:`, cachedProfileData); - - if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') { - isInUserLists = cachedProfileData.isInUserLists; - console.log(`[ProfileHeader] Using cached user list status for ${event.pubkey}: ${isInUserLists}`); + // First check if we have user list information in the profile prop + if (profile && typeof profile.isInUserLists === 'boolean') { + isInUserLists = profile.isInUserLists; + console.log(`[ProfileHeader] Using profile prop user list status for ${event.pubkey}: ${isInUserLists}`); } else { - console.log(`[ProfileHeader] No cached user list data, fetching for ${event.pubkey}`); - // Fallback to fetching user lists - fetchCurrentUserLists() - .then((userLists) => { - console.log(`[ProfileHeader] Fetched ${userLists.length} user lists for ${event.pubkey}`); - isInUserLists = isPubkeyInUserLists(event.pubkey, userLists); - console.log(`[ProfileHeader] Final user list status for ${event.pubkey}: ${isInUserLists}`); - }) - .catch((error) => { - console.error(`[ProfileHeader] Error fetching user lists for ${event.pubkey}:`, error); - isInUserLists = false; - }); + // Then check if we have cached profileData with user list information + const cachedProfileData = (event as any).profileData; + console.log(`[ProfileHeader] Checking user list status for ${event.pubkey}, cached profileData:`, cachedProfileData); + + if (cachedProfileData && typeof cachedProfileData.isInUserLists === 'boolean') { + isInUserLists = cachedProfileData.isInUserLists; + console.log(`[ProfileHeader] Using cached user list status for ${event.pubkey}: ${isInUserLists}`); + } else { + console.log(`[ProfileHeader] No cached user list data, fetching for ${event.pubkey}`); + // Fallback to fetching user lists + fetchCurrentUserLists() + .then((userLists) => { + console.log(`[ProfileHeader] Fetched ${userLists.length} user lists for ${event.pubkey}`); + isInUserLists = isPubkeyInUserLists(event.pubkey, userLists); + console.log(`[ProfileHeader] Final user list status for ${event.pubkey}: ${isInUserLists}`); + }) + .catch((error) => { + console.error(`[ProfileHeader] Error fetching user lists for ${event.pubkey}:`, error); + isInUserLists = false; + }); + } } // Check community status - use cached data if available @@ -141,6 +151,7 @@ profile.display_name || profile.name || event.pubkey, + ndk, )}
{#if communityStatus === true} @@ -199,7 +210,11 @@ {#if profile.about}
About:
-
{profile.about}
+
+
+ {@render basicMarkup(profile.about, ndk)} +
+
{/if} {#if profile.website} @@ -269,6 +284,7 @@ {@render userBadge( toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey, + ndk, )}

{profile.lud16}

diff --git a/src/lib/components/embedded_events/EmbeddedEvent.svelte b/src/lib/components/embedded_events/EmbeddedEvent.svelte deleted file mode 100644 index 30ef2dd..0000000 --- a/src/lib/components/embedded_events/EmbeddedEvent.svelte +++ /dev/null @@ -1,738 +0,0 @@ - - -{#if nestingLevel >= MAX_NESTING_LEVEL} - - -{:else if loading} - -
-
-
- Loading event... -
-
-{:else if error} - - -{:else if event} - -
- -
-
- - Kind {event.kind} - - - ({getEventType(event.kind || 0)}) - - {#if event.pubkey} - - Author: -
- {#if toNpub(event.pubkey)} - {@render userBadge( - toNpub(event.pubkey) as string, - authorDisplayName, - )} - {:else} - - {authorDisplayName || event.pubkey.slice(0, 8)}...{event.pubkey.slice(-4)} - - {/if} -
- {/if} -
-
- - - {#if getEventTitle(event)} -

- {getEventTitle(event)} -

- {/if} - - - {#if event.kind !== 1 && getEventSummary(event)} -
-

- {getEventSummary(event)} -

-
- {/if} - - - {#if event.kind === 1 || repostKinds.includes(event.kind)} -
- {#if repostKinds.includes(event.kind)} - -
-
- Reposted content: -
- {@render parsedContent(event.content.slice(0, 300))} - {#if event.content.length > 300} - ... - {/if} -
- {:else} - - {@render parsedContent(event.content.slice(0, 300))} - {#if event.content.length > 300} - ... - {/if} - {/if} -
- - {:else if event.kind === 3} -
- {#if event.content} - {@const contactData = (() => { - try { - return JSON.parse(event.content); - } catch { - return null; - } - })()} - {#if contactData} -
-
- Contact List - {#if contactData.relays} -
- Relays: {Object.keys(contactData.relays).length} -
- {/if} -
- {#if contactData.follows} -
- Following: {contactData.follows.length} users -
- {/if} -
- {:else} -
- Invalid contact list data -
- {/if} - {:else} -
- Empty contact list -
- {/if} -
- - {:else if event.kind === 30040} -
- {#if event.content} - {@const indexData = (() => { - try { - return JSON.parse(event.content); - } catch { - return null; - } - })()} - {#if indexData} -
-
- Publication Index - {#if indexData.title} -
- Title: {indexData.title} -
- {/if} - {#if indexData.summary} -
- Summary: {indexData.summary} -
- {/if} - {#if indexData.authors} -
- Authors: {indexData.authors.length} -
- {/if} -
-
- {:else} -
- Invalid publication index data -
- {/if} - {:else} -
- Empty publication index -
- {/if} -
- - {:else if event.kind === 30041 || event.kind === 30818} -
- {#if event.content} -
-
- - {event.kind === 30041 ? 'Publication Content' : 'Wiki Content'} - -
-
-
-                {event.content.slice(0, 300)}
-                {#if event.content.length > 300}
-                  ...
-                {/if}
-              
-
-
- {:else} -
- Empty {event.kind === 30041 ? 'publication' : 'wiki'} content -
- {/if} -
- - {:else if event.kind === 30023} -
- {#if event.content} -
-
- Long-form Content -
-
-
-                {event.content.slice(0, 300)}
-                {#if event.content.length > 300}
-                  ...
-                {/if}
-              
-
-
- {:else} -
- Empty long-form content -
- {/if} -
- - {:else if event.kind === 1111} -
-
-
- Reply/Comment -
- {#if event.content && event.content.trim()} -
- {@render parsedContent(event.content)} -
- {:else} -
- Empty reply -
- {/if} -
-
- - {:else if event.kind === 1621} -
-
-
- Git Issue - {#if event.tags} - {@const subjectTag = event.tags.find(tag => tag[0] === 'subject')} - {#if subjectTag && subjectTag[1]} -
- Subject: {subjectTag[1]} -
- {/if} - {/if} -
- {#if event.content && event.content.trim()} -
- {@render parsedContent(event.content)} -
- {:else} -
- Empty issue description -
- {/if} -
-
- - {:else if event.kind === 1622} -
-
-
- Git Comment -
- {#if event.content && event.content.trim()} -
- {@render parsedContent(event.content)} -
- {:else} -
- Empty comment -
- {/if} -
-
- - {:else if event.kind === 7} -
-
-
- Reaction -
- {#if event.content && event.content.trim()} -
- {event.content} -
- {:else} -
- Empty reaction -
- {/if} -
-
- - {:else if event.kind === 9735} -
-
-
- Zap Receipt -
- {#if event.content && event.content.trim()} - {@const zapData = (() => { - try { - return JSON.parse(event.content); - } catch { - return null; - } - })()} - {#if zapData} -
- {#if zapData.amount} -
Amount: {zapData.amount} sats
- {/if} - {#if zapData.preimage} -
Preimage: {zapData.preimage.slice(0, 8)}...
- {/if} - {#if zapData.bolt11} -
Invoice: {zapData.bolt11.slice(0, 20)}...
- {/if} -
- {:else} -
-
-                  {event.content.slice(0, 200)}
-                  {#if event.content.length > 200}
-                    ...
-                  {/if}
-                
-
- {/if} - {:else} -
- Empty zap receipt -
- {/if} -
-
- - {:else if event.kind === 20} -
-
-
- Image/Media Post -
- - - {#if event.tags} - {@const imetaTags = event.tags.filter(tag => tag[0] === 'imeta')} - {#if imetaTags.length > 0} -
- {#each imetaTags as imetaTag} - {@const imetaData = (() => { - const data: any = {}; - for (let i = 1; i < imetaTag.length; i++) { - const item = imetaTag[i]; - if (item.startsWith('url ')) { - data.url = item.substring(4); - } else if (item.startsWith('dim ')) { - data.dimensions = item.substring(4); - } else if (item.startsWith('m ')) { - data.mimeType = item.substring(2); - } else if (item.startsWith('size ')) { - data.size = item.substring(5); - } else if (item.startsWith('blurhash ')) { - data.blurhash = item.substring(9); - } else if (item.startsWith('x ')) { - data.x = item.substring(2); - } - } - return data; - })()} - - {#if imetaData.url && imetaData.mimeType?.startsWith('image/')} -
- imeta { - (e.target as HTMLImageElement).style.display = 'none'; - const fallback = (e.target as HTMLImageElement).nextElementSibling; - if (fallback) fallback.classList.remove('hidden'); - }} - /> - - - -
- {#if imetaData.dimensions} - Size: {imetaData.dimensions} - {/if} - {#if imetaData.size} - File: {Math.round(parseInt(imetaData.size) / 1024)}KB - {/if} - {#if imetaData.mimeType} - Type: {imetaData.mimeType} - {/if} -
-
- {:else if imetaData.url} - -
- - {#if imetaData.size} -
- Size: {Math.round(parseInt(imetaData.size) / 1024)}KB -
- {/if} -
- {/if} - {/each} -
- {/if} - {/if} - - - {#if event.content && event.content.trim()} -
- {@render parsedContent(event.content)} -
- {/if} - - - {#if event.tags} - {@const altTag = event.tags.find(tag => tag[0] === 'alt')} - {#if altTag && altTag[1]} -
- Alt: {altTag[1]} -
- {/if} - {/if} -
-
- - {:else if event.kind === 0 && profile} -
- {#if profile.picture} - Profile { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); - }} - /> - - {:else} -
- -
- {/if} - {#if profile.about} -

- {profile.about.slice(0, 200)} - {#if profile.about.length > 200} - ... - {/if} -

- {/if} -
- - {:else if event.content} -
-
-          {event.content.slice(0, 300)}
-          {#if event.content.length > 300}
-            ...
-          {/if}
-        
-
- {:else} -
- No content -
- {/if} - - -
-
- ID: - { - e.preventDefault(); - goto(`/events?id=${event!.id}`); - }} - > - {event!.id.slice(0, 8)}...{event!.id.slice(-4)} - - {#if isAddressableEvent(event!)} - Address: - - {getNaddrUrl(event!).slice(0, 12)}...{getNaddrUrl(event!).slice(-8)} - - {/if} -
-
-
-{/if} diff --git a/src/lib/components/event_input/EventForm.svelte b/src/lib/components/event_input/EventForm.svelte index 94bf99f..e25a0b0 100644 --- a/src/lib/components/event_input/EventForm.svelte +++ b/src/lib/components/event_input/EventForm.svelte @@ -3,7 +3,7 @@ import type { EventData, TagData, ValidationResult } from "./types"; import { validateEvent } from "./validation"; - // AI-NOTE: 2025-01-24 - EventForm component handles basic form inputs and validation + // AI-NOTE: EventForm component handles basic form inputs and validation // This component focuses on event kind and content, with validation feedback let { diff --git a/src/lib/components/event_input/EventPreview.svelte b/src/lib/components/event_input/EventPreview.svelte index 76697f2..723a23a 100644 --- a/src/lib/components/event_input/EventPreview.svelte +++ b/src/lib/components/event_input/EventPreview.svelte @@ -6,7 +6,7 @@ import { build30040EventSet } from "$lib/utils/event_input_utils"; import type { EventData, TagData, EventPreview } from "./types"; - // AI-NOTE: 2025-01-24 - EventPreview component shows a preview of the event that will be published + // AI-NOTE: EventPreview component shows a preview of the event that will be published // This component generates a preview based on the current form data let { diff --git a/src/lib/components/event_input/TagManager.svelte b/src/lib/components/event_input/TagManager.svelte index 648fe56..c715afe 100644 --- a/src/lib/components/event_input/TagManager.svelte +++ b/src/lib/components/event_input/TagManager.svelte @@ -3,7 +3,7 @@ import { titleToDTag, requiresDTag } from "$lib/utils/event_input_utils"; import type { TagData, PresetTag } from "./types"; - // AI-NOTE: 2025-01-24 - TagManager component handles tag management with preset tags + // AI-NOTE: TagManager component handles tag management with preset tags // This component automatically manages preset tags based on event kind and content let { diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 6170e15..29bab3d 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -109,7 +109,7 @@ // #endregion - // AI-NOTE: 2025-01-24 - Combined effect to handle publicationTree changes and initial loading + // AI-NOTE: Combined effect to handle publicationTree changes and initial loading // This prevents conflicts between separate effects that could cause duplicate loading $effect(() => { if (publicationTree) { @@ -126,7 +126,7 @@ publicationTree.resetIterator(); } - // AI-NOTE: 2025-01-24 - Use setTimeout to ensure iterator reset completes before loading + // AI-NOTE: Use setTimeout to ensure iterator reset completes before loading // This prevents race conditions where loadMore is called before the iterator is fully reset setTimeout(() => { // Load initial content after reset @@ -235,7 +235,7 @@ { threshold: 0.5 }, ); - // AI-NOTE: 2025-01-24 - Removed duplicate loadMore call + // AI-NOTE: Removed duplicate loadMore call // Initial content loading is handled by the $effect that watches publicationTree // This prevents duplicate loading when both onMount and $effect trigger diff --git a/src/lib/components/publications/PublicationHeader.svelte b/src/lib/components/publications/PublicationHeader.svelte index 5cab792..017f3a6 100644 --- a/src/lib/components/publications/PublicationHeader.svelte +++ b/src/lib/components/publications/PublicationHeader.svelte @@ -1,7 +1,7 @@ + + +{#snippet basicMarkup(text: string, ndk?: NDK)} + {#await parseBasicMarkup(text, ndk) then parsed} + {@html parsed} + {:catch error: Error} +
Error processing markup: {error.message}
+ {/await} +{/snippet} diff --git a/src/lib/snippets/UserSnippets.svelte b/src/lib/snippets/UserSnippets.svelte index d069c94..c687b73 100644 --- a/src/lib/snippets/UserSnippets.svelte +++ b/src/lib/snippets/UserSnippets.svelte @@ -6,28 +6,30 @@ getUserMetadata, } from "$lib/utils/nostrUtils"; import type { UserProfile } from "$lib/models/user_profile"; + import NDK from "@nostr-dev-kit/ndk"; export { userBadge }; -{#snippet userBadge(identifier: string, displayText: string | undefined)} +{#snippet userBadge(identifier: string, displayText: string | undefined, ndk?: NDK)} {@const npub = toNpub(identifier)} {#if npub} {#if !displayText || displayText.trim().toLowerCase() === "unknown"} - {#await getUserMetadata(npub, undefined, false) then profile} + {#await getUserMetadata(npub, ndk, false) then profile} {@const p = profile as UserProfile} - {:catch} + {:catch error} + {@const debugError = console.error("Error fetching profile for", npub, ":", error)} - `; -} - -/** - * Shared service for processing media URLs - */ -export function processMediaUrl(url: string, alt?: string): string { - const clean = stripTrackingParams(url); - - if (YOUTUBE_URL_REGEX.test(clean)) { - const videoId = extractYouTubeVideoId(clean); - if (videoId) { - return ``; - } - } - - if (VIDEO_URL_REGEX.test(clean)) { - return ``; - } - - if (AUDIO_URL_REGEX.test(clean)) { - return ``; - } - - if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) { - return processImageWithReveal(clean, alt || "Embedded media"); - } - - // Default to clickable link - return `${clean}`; -} - -/** - * Shared service for processing nostr identifiers - */ -export async function processNostrIdentifiersInText( - text: string, - ndk?: NDK, -): Promise { - let processedText = text; - - // Find all profile-related nostr addresses (only npub and nprofile) - const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX)); - - // Process them in reverse order to avoid index shifting issues - for (let i = matches.length - 1; i >= 0; i--) { - const match = matches[i]; - const [fullMatch] = match; - const matchIndex = match.index ?? 0; - - // Skip if part of a URL - const before = processedText.slice( - Math.max(0, matchIndex - 12), - matchIndex, - ); - if (/https?:\/\/$|www\.$/i.test(before)) { - continue; - } - - // Process the nostr identifier directly - let identifier = fullMatch; - if (!identifier.startsWith("nostr:")) { - identifier = "nostr:" + identifier; - } - - // Get user metadata and create link - let metadata; - if (ndk) { - metadata = await getUserMetadata(identifier, ndk); - } else { - // Fallback when NDK is not available - just use the identifier - metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) }; - } - const displayText = metadata.displayName || metadata.name; - const link = createProfileLink(identifier, displayText); - - // Replace the match in the text - processedText = processedText.slice(0, matchIndex) + link + - processedText.slice(matchIndex + fullMatch.length); - } - - return processedText; -} - -/** - * Shared service for processing nostr identifiers with embedded events - * Replaces nostr: links with embedded event placeholders - * Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile) - */ -export function processNostrIdentifiersWithEmbeddedEvents( - text: string, - nestingLevel: number = 0, -): string { - const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g; - let processedText = text; - - // Maximum nesting level allowed - const MAX_NESTING_LEVEL = 3; - - // Find all event-related nostr addresses - const matches = Array.from(processedText.matchAll(eventPattern)); - - // Process them in reverse order to avoid index shifting issues - for (let i = matches.length - 1; i >= 0; i--) { - const match = matches[i]; - const [fullMatch] = match; - const matchIndex = match.index ?? 0; - - let replacement: string; - - if (nestingLevel >= MAX_NESTING_LEVEL) { - // At max nesting level, just show the link - replacement = - `${fullMatch}`; - } else { - // Create a placeholder for embedded event - const componentId = `embedded-event-${ - Math.random().toString(36).substr(2, 9) - }`; - replacement = - `
`; - } - - // Replace the match in the text - processedText = processedText.slice(0, matchIndex) + replacement + - processedText.slice(matchIndex + fullMatch.length); - } - - return processedText; -} - -/** - * Shared service for processing emoji shortcodes - */ -export function processEmojiShortcodes(text: string): string { - return emoji.emojify(text); -} - -/** - * Shared service for processing WebSocket URLs - */ -export function processWebSocketUrls(text: string): string { - const wssUrlRegex = /wss:\/\/[^\s<>"]+/g; - return text.replace(wssUrlRegex, (match) => { - const cleanUrl = match.slice(6).replace(/\/+$/, ""); - return `${match}`; - }); -} - -/** - * Shared service for processing hashtags - */ -export function processHashtags(text: string): string { - const hashtagRegex = /(?#$1', - ); -} - -/** - * Shared service for processing basic text formatting - */ -export function processBasicTextFormatting(text: string): string { - // Bold: **text** or *text* - text = text.replace( - /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g, - "$2", - ); - - // Italic: _text_ or __text__ - text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => { - const text = match.replace(/^_+|_+$/g, ""); - return `${text}`; - }); - - // Strikethrough: ~~text~~ or ~text~ - text = text.replace( - /~~([^~\n]+)~~|~([^~\n]+)~/g, - (_match, doubleText, singleText) => { - const text = doubleText || singleText; - return `${text}`; - }, - ); - - return text; -} - -/** - * Shared service for processing blockquotes - */ -export function processBlockquotes(text: string): string { - const blockquoteRegex = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; - return text.replace(blockquoteRegex, (match) => { - const lines = match.split("\n").map((line) => { - return line.replace(/^[ \t]*>[ \t]?/, "").trim(); - }); - return `
${ - lines.join("\n") - }
`; - }); -} - -// Helper functions -export function stripTrackingParams(url: string): string { - try { - const urlObj = new URL(url); - // Remove common tracking parameters - const trackingParams = [ - "utm_source", - "utm_medium", - "utm_campaign", - "utm_term", - "utm_content", - "fbclid", - "gclid", - ]; - trackingParams.forEach((param) => urlObj.searchParams.delete(param)); - return urlObj.toString(); - } catch { - return url; - } -} - -function extractYouTubeVideoId(url: string): string | null { - const match = url.match( - /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/, - ); - return match ? match[1] : null; -} - -/** - * Normalizes a string for use as a d-tag by converting to lowercase, - * replacing non-alphanumeric characters with dashes, and removing - * leading/trailing dashes. - */ -function normalizeDTag(input: string): string { - return input - .toLowerCase() - .replace(/[^\p{L}\p{N}]/gu, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); -} - -/** - * Shared service for processing wikilinks in the format [[target]] or [[target|display]] - */ -export function processWikilinks(text: string): string { - // [[target page]] or [[target page|display text]] - return text.replace( - /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, - (_match, target, label) => { - const normalized = normalizeDTag(target.trim()); - const display = (label || target).trim(); - const url = `/events?d=${normalized}`; - return `${display}`; - }, - ); -} - -/** - * Shared service for processing AsciiDoc anchor tags - */ -export function processAsciiDocAnchors(text: string): string { - return text.replace(/<\/a>/g, (_match, id) => { - const normalized = normalizeDTag(id.trim()); - const url = `/events?d=${normalized}`; - return `${id}`; - }); -} diff --git a/src/lib/utils/markup/markupUtils.ts b/src/lib/utils/markup/markupUtils.ts new file mode 100644 index 0000000..9417386 --- /dev/null +++ b/src/lib/utils/markup/markupUtils.ts @@ -0,0 +1,629 @@ +import NDK from "@nostr-dev-kit/ndk"; +import { + createProfileLink, + getUserMetadata, + nip19, + NOSTR_PROFILE_REGEX, +} from "../nostrUtils.ts"; + +import * as emoji from "node-emoji"; + +// Media URL patterns +const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i; +const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i; +const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i; +const YOUTUBE_URL_REGEX = + /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/; + +// Links and media +const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; +const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; +// AI-NOTE: Added negative lookbehind (?"]+)(?!["'])/g; + +// Add this helper function near the top: +export function replaceAlexandriaNostrLinks(text: string): string { + // Regex for Alexandria/localhost URLs + const alexandriaPattern = + /^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i; + // Regex for bech32 Nostr identifiers + const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/; + // Regex for 64-char hex + const hexPattern = /\b[a-fA-F0-9]{64}\b/; + + // 1. Alexandria/localhost markup links + text = text.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + (match, _label, url) => { + if (alexandriaPattern.test(url)) { + if (/[?&]d=/.test(url)) return match; + const hexMatch = url.match(hexPattern); + if (hexMatch) { + try { + const nevent = nip19.neventEncode({ id: hexMatch[0] }); + return `nostr:${nevent}`; + } catch { + return match; + } + } + const bech32Match = url.match(bech32Pattern); + if (bech32Match) { + return `nostr:${bech32Match[0]}`; + } + } + return match; + }, + ); + + // 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers + text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { + if (alexandriaPattern.test(url)) { + if (/[?&]d=/.test(url)) return url; + const hexMatch = url.match(hexPattern); + if (hexMatch) { + try { + const nevent = nip19.neventEncode({ id: hexMatch[0] }); + return `nostr:${nevent}`; + } catch { + return url; + } + } + const bech32Match = url.match(bech32Pattern); + if (bech32Match) { + return `nostr:${bech32Match[0]}`; + } + } + // For non-Alexandria/localhost URLs, just return the URL as-is + return url; + }); + + return text; +} + +export function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string { + function parseList( + start: number, + indent: number, + type: "ol" | "ul", + ): [string, number] { + let html = ""; + let i = start; + html += `<${type} class="${ + type === "ol" ? "list-decimal" : "list-disc" + } ml-6 mb-2">`; + while (i < lines.length) { + const line = lines[i]; + const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); + if (!match) break; + const lineIndent = match[1].replace(/\t/g, " ").length; + const isOrdered = /\d+\./.test(match[2]); + const itemType = isOrdered ? "ol" : "ul"; + if (lineIndent > indent) { + // Nested list + const [nestedHtml, consumed] = parseList(i, lineIndent, itemType); + html = html.replace(/<\/li>$/, "") + nestedHtml + ""; + i = consumed; + continue; + } + if (lineIndent < indent || itemType !== type) { + break; + } + html += `
  • ${match[3]}`; + // Check for next line being a nested list + if (i + 1 < lines.length) { + const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); + if (nextMatch) { + const nextIndent = nextMatch[1].replace(/\t/g, " ").length; + const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul"; + if (nextIndent > lineIndent) { + const [nestedHtml, consumed] = parseList( + i + 1, + nextIndent, + nextType, + ); + html += nestedHtml; + i = consumed - 1; + } + } + } + html += "
  • "; + i++; + } + html += ``; + return [html, i]; + } + if (!lines.length) return ""; + const firstLine = lines[0]; + const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); + const indent = match ? match[1].replace(/\t/g, " ").length : 0; + const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul"); + const [html] = parseList(0, indent, type); + return html; +} + +export function processBasicFormatting(content: string): string { + if (!content) return ""; + + let processedText = content; + + try { + // Sanitize Alexandria Nostr links before further processing + processedText = replaceAlexandriaNostrLinks(processedText); + + // Process markup images first + processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => { + // Clean the URL and alt text + const cleanUrl = url.trim(); + const cleanAlt = alt ? alt.trim() : ""; + return processImageWithReveal(cleanUrl, cleanAlt); + }); + + // Process markup links + processedText = processedText.replace( + MARKUP_LINK, + (_match, text, url) => + `${text}`, + ); + + // Process WebSocket URLs using shared services + processedText = processWebSocketUrls(processedText); + + // Process direct media URLs and auto-link all URLs + processedText = processedText.replace(DIRECT_LINK, (match) => { + return processMediaUrl(match); + }); + + // Process text formatting using shared services + processedText = processBasicTextFormatting(processedText); + + // Process hashtags using shared services + processedText = processHashtags(processedText); + + // --- Improved List Grouping and Parsing --- + const lines = processedText.split("\n"); + let output = ""; + let buffer: string[] = []; + let inList = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) { + buffer.push(line); + inList = true; + } else { + if (inList) { + const firstLine = buffer[0]; + const isOrdered = /^\s*\d+\.\s+/.test(firstLine); + output += renderListGroup(buffer, isOrdered ? "ol" : "ul"); + buffer = []; + inList = false; + } + output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n"; + } + } + if (buffer.length) { + const firstLine = buffer[0]; + const isOrdered = /^\s*\d+\.\s+/.test(firstLine); + output += renderListGroup(buffer, isOrdered ? "ol" : "ul"); + } + processedText = output; + // --- End Improved List Grouping and Parsing --- + } catch (e: unknown) { + console.error("Error in processBasicFormatting:", e); + } + + return processedText; +} + +/** + * Shared service for processing images with expand functionality + */ +export function processImageWithReveal( + src: string, + alt: string = "Image", +): string { + if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) { + return `${alt}`; + } + + return `
    + ${alt} + + + +
    `; +} + +/** + * Shared service for processing media URLs + */ +export function processMediaUrl(url: string, alt?: string): string { + const clean = stripTrackingParams(url); + + if (YOUTUBE_URL_REGEX.test(clean)) { + const videoId = extractYouTubeVideoId(clean); + if (videoId) { + return ``; + } + } + + if (VIDEO_URL_REGEX.test(clean)) { + return ``; + } + + if (AUDIO_URL_REGEX.test(clean)) { + return ``; + } + + if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) { + return processImageWithReveal(clean, alt || "Embedded media"); + } + + // Default to clickable link + return `${clean}`; +} + +/** + * Shared service for processing nostr identifiers + */ +export async function processNostrIdentifiersInText( + text: string, + ndk?: NDK, +): Promise { + let processedText = text; + + // Find all profile-related nostr addresses (only npub and nprofile) + const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX)); + + // Process them in reverse order to avoid index shifting issues + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i]; + const [fullMatch] = match; + const matchIndex = match.index ?? 0; + + // Skip if part of a URL + const before = processedText.slice( + Math.max(0, matchIndex - 12), + matchIndex, + ); + if (/https?:\/\/$|www\.$/i.test(before)) { + continue; + } + + // Process the nostr identifier directly + let identifier = fullMatch; + if (!identifier.startsWith("nostr:")) { + identifier = "nostr:" + identifier; + } + + // Get user metadata and create link + let metadata; + if (ndk) { + metadata = await getUserMetadata(identifier, ndk); + } else { + // Fallback when NDK is not available - just use the identifier + metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) }; + } + const displayText = metadata.displayName || metadata.name; + const link = createProfileLink(identifier, displayText); + + // Replace the match in the text + processedText = processedText.slice(0, matchIndex) + link + + processedText.slice(matchIndex + fullMatch.length); + } + + return processedText; +} + +/** + * Shared service for processing nostr identifiers with embedded events + * Replaces nostr: links with embedded event placeholders + * Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile) + */ +export function processNostrIdentifiersWithEmbeddedEvents( + text: string, + nestingLevel: number = 0, +): string { + const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g; + let processedText = text; + + // Maximum nesting level allowed + const MAX_NESTING_LEVEL = 3; + + // Find all event-related nostr addresses + const matches = Array.from(processedText.matchAll(eventPattern)); + + // Process them in reverse order to avoid index shifting issues + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i]; + const [fullMatch] = match; + const matchIndex = match.index ?? 0; + + let replacement: string; + + if (nestingLevel >= MAX_NESTING_LEVEL) { + // At max nesting level, just show the link + replacement = + `${fullMatch}`; + } else { + // Create a placeholder for embedded event + const componentId = `embedded-event-${ + Math.random().toString(36).substr(2, 9) + }`; + replacement = + `
    `; + } + + // Replace the match in the text + processedText = processedText.slice(0, matchIndex) + replacement + + processedText.slice(matchIndex + fullMatch.length); + } + + return processedText; +} + +/** + * Shared service for processing all nostr identifiers (both profiles and events) + * Creates clickable links for all nostr identifiers + */ +export function processAllNostrIdentifiers(text: string): string { + let processedText = text; + + // Pattern for prefixed nostr identifiers (nostr:npub1, nostr:note1, etc.) + // This handles both full identifiers and partial ones that might appear in content + const prefixedNostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g; + + // Pattern for bare nostr identifiers (npub1, note1, nevent1, naddr1) + // Exclude matches that are part of URLs to avoid breaking existing links + const bareNostrPattern = /(?= 0; i--) { + const match = prefixedMatches[i]; + const [fullMatch] = match; + const matchIndex = match.index ?? 0; + + // Create shortened display text + const identifier = fullMatch.replace('nostr:', ''); + const displayText = `${identifier.slice(0, 8)}...${identifier.slice(-4)}`; + + // Create clickable link + const replacement = `${displayText}`; + + // Replace the match in the text + processedText = processedText.slice(0, matchIndex) + replacement + + processedText.slice(matchIndex + fullMatch.length); + } + + // Process bare nostr identifiers + const bareMatches = Array.from(processedText.matchAll(bareNostrPattern)); + + // Process them in reverse order to avoid index shifting issues + for (let i = bareMatches.length - 1; i >= 0; i--) { + const match = bareMatches[i]; + const [fullMatch] = match; + const matchIndex = match.index ?? 0; + + // Create shortened display text + const displayText = `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}`; + + // Create clickable link with nostr: prefix for the href + const replacement = `${displayText}`; + + // Replace the match in the text + processedText = processedText.slice(0, matchIndex) + replacement + + processedText.slice(matchIndex + fullMatch.length); + } + + // Also handle any remaining truncated prefixed identifiers that might be cut off or incomplete + const truncatedPrefixedPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{8,}/g; + const truncatedPrefixedMatches = Array.from(processedText.matchAll(truncatedPrefixedPattern)); + + for (let i = truncatedPrefixedMatches.length - 1; i >= 0; i--) { + const match = truncatedPrefixedMatches[i]; + const [fullMatch] = match; + const matchIndex = match.index ?? 0; + + // Skip if this was already processed by the main pattern + if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars + + // Create display text for truncated identifiers + const identifier = fullMatch.replace('nostr:', ''); + const displayText = identifier.length > 12 ? `${identifier.slice(0, 8)}...${identifier.slice(-4)}` : identifier; + + // Create clickable link + const replacement = `${displayText}`; + + // Replace the match in the text + processedText = processedText.slice(0, matchIndex) + replacement + + processedText.slice(matchIndex + fullMatch.length); + } + + // Handle truncated bare identifiers + const truncatedBarePattern = /(?= 0; i--) { + const match = truncatedBareMatches[i]; + const [fullMatch] = match; + const matchIndex = match.index ?? 0; + + // Skip if this was already processed by the main pattern + if (fullMatch.length >= 30) continue; // Full identifiers are at least 30 chars + + // Create display text for truncated identifiers + const displayText = fullMatch.length > 12 ? `${fullMatch.slice(0, 8)}...${fullMatch.slice(-4)}` : fullMatch; + + // Create clickable link + const replacement = `${displayText}`; + + // Replace the match in the text + processedText = processedText.slice(0, matchIndex) + replacement + + processedText.slice(matchIndex + fullMatch.length); + } + + return processedText; +} + +/** + * Shared service for processing emoji shortcodes + */ +export function processEmojiShortcodes(text: string): string { + return emoji.emojify(text); +} + +/** + * Shared service for processing WebSocket URLs + */ +export function processWebSocketUrls(text: string): string { + const wssUrlRegex = /wss:\/\/[^\s<>"]+/g; + return text.replace(wssUrlRegex, (match) => { + const cleanUrl = match.slice(6).replace(/\/+$/, ""); + return `${match}`; + }); +} + +/** + * Shared service for processing hashtags + */ +export function processHashtags(text: string): string { + const hashtagRegex = /(?#$1', + ); +} + +/** + * Shared service for processing basic text formatting + */ +export function processBasicTextFormatting(text: string): string { + // Bold: **text** or *text* + text = text.replace( + /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g, + "$2", + ); + + // Italic: _text_ or __text__ + text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => { + const text = match.replace(/^_+|_+$/g, ""); + return `${text}`; + }); + + // Strikethrough: ~~text~~ or ~text~ + text = text.replace( + /~~([^~\n]+)~~|~([^~\n]+)~/g, + (_match, doubleText, singleText) => { + const text = doubleText || singleText; + return `${text}`; + }, + ); + + return text; +} + +/** + * Shared service for processing blockquotes + */ +export function processBlockquotes(text: string): string { + const blockquoteRegex = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; + return text.replace(blockquoteRegex, (match) => { + const lines = match.split("\n").map((line) => { + return line.replace(/^[ \t]*>[ \t]?/, "").trim(); + }); + return `
    ${ + lines.join("\n") + }
    `; + }); +} + +// Helper functions +export function stripTrackingParams(url: string): string { + try { + const urlObj = new URL(url); + // Remove common tracking parameters + const trackingParams = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "fbclid", + "gclid", + ]; + trackingParams.forEach((param) => urlObj.searchParams.delete(param)); + return urlObj.toString(); + } catch { + return url; + } +} + +function extractYouTubeVideoId(url: string): string | null { + const match = url.match( + /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/, + ); + return match ? match[1] : null; +} + +/** + * Normalizes a string for use as a d-tag by converting to lowercase, + * replacing non-alphanumeric characters with dashes, and removing + * leading/trailing dashes. + */ +function normalizeDTag(input: string): string { + return input + .toLowerCase() + .replace(/[^\p{L}\p{N}]/gu, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Shared service for processing wikilinks in the format [[target]] or [[target|display]] + */ +export function processWikilinks(text: string): string { + // [[target page]] or [[target page|display text]] + return text.replace( + /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, + (_match, target, label) => { + const normalized = normalizeDTag(target.trim()); + const display = (label || target).trim(); + const url = `/events?d=${normalized}`; + return `${display}`; + }, + ); +} + +/** + * Shared service for processing AsciiDoc anchor tags + */ +export function processAsciiDocAnchors(text: string): string { + return text.replace(/<\/a>/g, (_match, id) => { + const normalized = normalizeDTag(id.trim()); + const url = `/events?d=${normalized}`; + return `${id}`; + }); +} diff --git a/src/lib/utils/mime.ts b/src/lib/utils/mime.ts index a8714c3..3b8e416 100644 --- a/src/lib/utils/mime.ts +++ b/src/lib/utils/mime.ts @@ -62,6 +62,18 @@ export function getMimeTags(kind: number): [string, string][] { MTag = ["M", `note/microblog/${replaceability}`]; break; + // Repost (NIP-18) + case 6: + mTag = ["m", "application/json"]; + MTag = ["M", `note/repost/${replaceability}`]; + break; + + // Generic repost (NIP-18) + case 16: + mTag = ["m", "application/json"]; + MTag = ["M", `note/generic-repost/${replaceability}`]; + break; + // Generic reply case 1111: mTag = ["m", "text/plain"]; diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts index ef3e8ca..0495fcf 100644 --- a/src/lib/utils/nostrUtils.ts +++ b/src/lib/utils/nostrUtils.ts @@ -179,7 +179,7 @@ export async function createProfileLinkWithVerification( /** * Create a note link element */ -function createNoteLink(identifier: string): string { +export function createNoteLink(identifier: string): string { const cleanId = identifier.replace(/^nostr:/, ""); const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; const escapedId = escapeHtml(cleanId); @@ -191,6 +191,7 @@ function createNoteLink(identifier: string): string { /** * Process Nostr identifiers in text */ +// AI-NOTE: Enhanced URL detection to prevent processing nostr identifiers that are part of URLs export async function processNostrIdentifiers( content: string, ndk: NDK, @@ -201,7 +202,25 @@ export async function processNostrIdentifiers( function isPartOfUrl(text: string, index: number): boolean { // Look for http(s):// or www. before the match const before = text.slice(Math.max(0, index - 12), index); - return /https?:\/\/$|www\.$/i.test(before); + if (/https?:\/\/$|www\.$/i.test(before)) { + return true; + } + + // Check if the match is part of a larger URL structure + // Look for common URL patterns that might contain nostr identifiers + const beforeContext = text.slice(Math.max(0, index - 50), index); + const afterContext = text.slice(index, Math.min(text.length, index + 50)); + + // Check if there's a URL-like structure around the match + const urlPatterns = [ + /https?:\/\/[^\s]*$/i, // URL starting with http(s):// + /www\.[^\s]*$/i, // URL starting with www. + /[^\s]*\.(com|org|net|io|eu|co|me|app|dev)[^\s]*$/i, // Common TLDs + /[^\s]*\/[^\s]*$/i, // Path-like structures + ]; + + const combinedContext = beforeContext + afterContext; + return urlPatterns.some(pattern => pattern.test(combinedContext)); } // Process profiles (npub and nprofile) @@ -392,7 +411,7 @@ export async function fetchEventWithFallback( filterOrId: string | Filter, timeoutMs: number = 10000, ): Promise { - // AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive event discovery + // AI-NOTE: Use ALL available relays for comprehensive event discovery // This ensures we don't miss events that might be on any available relay // Get all relays from NDK pool first (most comprehensive) @@ -418,7 +437,7 @@ export async function fetchEventWithFallback( "fetchEventWithFallback: No relays available for event fetch, using fallback relays", ); // Use fallback relays when no relays are available - // AI-NOTE: 2025-01-24 - Include ALL available relays for comprehensive event discovery + // AI-NOTE: Include ALL available relays for comprehensive event discovery // This ensures we don't miss events that might be on any available relay allRelays = [ ...secondaryRelays, diff --git a/src/lib/utils/npubCache.ts b/src/lib/utils/npubCache.ts index bc50d7b..4fcadfa 100644 --- a/src/lib/utils/npubCache.ts +++ b/src/lib/utils/npubCache.ts @@ -124,6 +124,7 @@ class UnifiedProfileCache { const metadata: NostrProfile = { name: profile?.name || fallback.name, displayName: profile?.displayName || profile?.display_name, + display_name: profile?.display_name || profile?.displayName, // AI-NOTE: Added for compatibility nip05: profile?.nip05, picture: profile?.picture || profile?.image, about: profile?.about, diff --git a/src/lib/utils/profile_search.ts b/src/lib/utils/profile_search.ts index 550f87d..09067ce 100644 --- a/src/lib/utils/profile_search.ts +++ b/src/lib/utils/profile_search.ts @@ -79,7 +79,7 @@ export async function searchProfiles( if (npub) { const metadata = await getUserMetadata(npub, ndk); - // AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at + // AI-NOTE: Fetch the original event timestamp to preserve created_at let created_at: number | undefined = undefined; try { const decoded = nip19.decode(npub); @@ -208,7 +208,7 @@ async function searchNip05Domains( ); const metadata = await getUserMetadata(npub, ndk); - // AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at + // AI-NOTE: Fetch the original event timestamp to preserve created_at let created_at: number | undefined = undefined; try { const decoded = nip19.decode(npub); @@ -260,7 +260,7 @@ async function searchNip05Domains( console.log("NIP-05 search: found npub for", nip05Address, ":", npub); const metadata = await getUserMetadata(npub, ndk); - // AI-NOTE: 2025-01-24 - Fetch the original event timestamp to preserve created_at + // AI-NOTE: Fetch the original event timestamp to preserve created_at let created_at: number | undefined = undefined; try { const decoded = nip19.decode(npub); @@ -326,7 +326,7 @@ async function quickRelaySearch( const normalizedSearchTerm = normalizeSearchTerm(searchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm); - // AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive profile discovery + // AI-NOTE: Use ALL available relays for comprehensive profile discovery // This ensures we don't miss profiles due to stale cache or limited relay coverage // Get all available relays from NDK pool (most comprehensive) diff --git a/src/lib/utils/relayDiagnostics.ts b/src/lib/utils/relayDiagnostics.ts deleted file mode 100644 index 2e650e3..0000000 --- a/src/lib/utils/relayDiagnostics.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { WebSocketPool } from "../data_structures/websocket_pool.ts"; -import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; -import { TIMEOUTS } from "./search_constants.ts"; -import { get } from "svelte/store"; - -export interface RelayDiagnostic { - url: string; - connected: boolean; - requiresAuth: boolean; - error?: string; - responseTime?: number; -} - -/** - * Tests connection to a single relay - */ -export async function testRelay(url: string): Promise { - const startTime = Date.now(); - const ws = await WebSocketPool.instance.acquire(url); - - return new Promise((resolve) => { - const timeout = setTimeout(() => { - WebSocketPool.instance.release(ws); - resolve({ - url, - connected: false, - requiresAuth: false, - error: "Connection timeout", - responseTime: Date.now() - startTime, - }); - }, TIMEOUTS.RELAY_DIAGNOSTICS); - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data[0] === "NOTICE" && data[1]?.includes("auth-required")) { - clearTimeout(timeout); - WebSocketPool.instance.release(ws); - resolve({ - url, - connected: true, - requiresAuth: true, - responseTime: Date.now() - startTime, - }); - } - }; - }); -} - -/** - * Tests all relays and returns diagnostic information - */ -export async function testAllRelays(): Promise { - // Use the new relay management system - const allRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; - - console.log("[RelayDiagnostics] Testing", allRelays.length, "relays..."); - - const results = await Promise.allSettled( - allRelays.map((url) => testRelay(url)), - ); - - return results.map((result, index) => { - if (result.status === "fulfilled") { - return result.value; - } else { - return { - url: allRelays[index], - connected: false, - requiresAuth: false, - error: "Test failed", - }; - } - }); -} - -/** - * Gets working relays from diagnostic results - */ -export function getWorkingRelays(diagnostics: RelayDiagnostic[]): string[] { - return diagnostics.filter((d) => d.connected).map((d) => d.url); -} - -/** - * Logs relay diagnostic results to console - */ -export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void { - console.group("[RelayDiagnostics] Results"); - - const working = diagnostics.filter((d) => d.connected); - const failed = diagnostics.filter((d) => !d.connected); - - console.log(`✅ Working relays (${working.length}):`); - working.forEach((d) => { - console.log( - ` - ${d.url}${d.requiresAuth ? " (requires auth)" : ""}${ - d.responseTime ? ` (${d.responseTime}ms)` : "" - }`, - ); - }); - - if (failed.length > 0) { - console.log(`❌ Failed relays (${failed.length}):`); - failed.forEach((d) => { - console.log(` - ${d.url}: ${d.error || "Unknown error"}`); - }); - } - - console.groupEnd(); -} diff --git a/src/lib/utils/search_constants.ts b/src/lib/utils/search_constants.ts index 5226782..5d11539 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: 3000, // AI-NOTE: 2025-01-24 - Reduced timeout since we limit scope + SECOND_ORDER_SEARCH: 30000, // AI-NOTE: Increased timeout to allow more time for relay responses /** Timeout for relay diagnostics */ RELAY_DIAGNOSTICS: 5000, @@ -47,22 +47,25 @@ export const SEARCH_LIMITS = { SPECIFIC_PROFILE: 10, /** Limit for general profile searches */ - GENERAL_PROFILE: 100, // AI-NOTE: 2025-01-24 - Reduced from 500 to prevent wild searches + GENERAL_PROFILE: 100, // AI-NOTE: 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 + GENERAL_CONTENT: 100, // AI-NOTE: Added limit for all content searches /** Limit for community relay checks */ COMMUNITY_CHECK: 1, /** Limit for second-order search results */ - SECOND_ORDER_RESULTS: 100, + SECOND_ORDER_RESULTS: 50, // AI-NOTE: Reduced to improve performance and reduce timeout issues /** Maximum results for profile searches */ MAX_PROFILE_RESULTS: 20, /** Batch size for profile fetching operations */ BATCH_SIZE: 50, + + /** Maximum events to fetch before processing in subscription search */ + SUBSCRIPTION_FETCH_LIMIT: 1000, } as const; // Nostr event kind ranges diff --git a/src/lib/utils/search_result_formatter.ts b/src/lib/utils/search_result_formatter.ts index 2e946d7..4ed8efc 100644 --- a/src/lib/utils/search_result_formatter.ts +++ b/src/lib/utils/search_result_formatter.ts @@ -1,6 +1,6 @@ /** * Utility class for formatting search result messages - * AI-NOTE: 2025-01-24 - Extracted from EventSearch component for better separation of concerns + * AI-NOTE: Extracted from EventSearch component for better separation of concerns */ export class SearchResultFormatter { /** diff --git a/src/lib/utils/search_types.ts b/src/lib/utils/search_types.ts index 46da61e..0993ed3 100644 --- a/src/lib/utils/search_types.ts +++ b/src/lib/utils/search_types.ts @@ -20,6 +20,7 @@ export interface Filter { export interface NostrProfile { name?: string; displayName?: string; + display_name?: string; // AI-NOTE: Added for compatibility with existing code nip05?: string; picture?: string; about?: string; @@ -29,7 +30,7 @@ export interface NostrProfile { pubkey?: string; isInUserLists?: boolean; listKinds?: number[]; - created_at?: number; // AI-NOTE: 2025-01-24 - Timestamp for proper date display + created_at?: number; // AI-NOTE: Timestamp for proper date display } /** @@ -64,8 +65,8 @@ export type SearchSubscriptionType = "d" | "t" | "n"; export interface SearchFilter { filter: Filter; subscriptionType: string; - searchTerm?: string; // AI-NOTE: 2025-01-24 - Optional search term for client-side filtering - preloadedEvents?: NDKEvent[]; // AI-NOTE: 2025-01-24 - Preloaded events for profile searches + searchTerm?: string; // AI-NOTE: Optional search term for client-side filtering + preloadedEvents?: NDKEvent[]; // AI-NOTE: Preloaded events for profile searches } /** diff --git a/src/lib/utils/search_utils.ts b/src/lib/utils/search_utils.ts index 8ad6243..5de3c22 100644 --- a/src/lib/utils/search_utils.ts +++ b/src/lib/utils/search_utils.ts @@ -106,8 +106,8 @@ export function createProfileFromEvent(event: NDKEvent, profileData: any): any { website: profileData.website, lud16: profileData.lud16, pubkey: event.pubkey, - created_at: event.created_at, // AI-NOTE: 2025-01-24 - Preserve timestamp for proper date display - isInUserLists: profileData.isInUserLists, // AI-NOTE: 2025-01-24 - Preserve user list information - listKinds: profileData.listKinds, // AI-NOTE: 2025-01-24 - Preserve list kinds information + created_at: event.created_at, // AI-NOTE: Preserve timestamp for proper date display + isInUserLists: profileData.isInUserLists, // AI-NOTE: Preserve user list information + listKinds: profileData.listKinds, // AI-NOTE: Preserve list kinds information }; } diff --git a/src/lib/utils/subscription_search.ts b/src/lib/utils/subscription_search.ts index bb6d692..bb9a415 100644 --- a/src/lib/utils/subscription_search.ts +++ b/src/lib/utils/subscription_search.ts @@ -25,6 +25,168 @@ const normalizeUrl = (url: string): string => { return url.replace(/\/$/, ""); // Remove trailing slash }; +// AI-NOTE: Define prioritized event kinds for subscription search +const PRIORITIZED_EVENT_KINDS = new Set([ + 1, // Text notes + 1111, // Comments + 9802, // Highlights + 20, // Article + 21, // Article + 22, // Article + 1222, // Long-form content + 1244, // Long-form content + 30023, // Long-form content + 30040, // Long-form content + 30041, // Long-form content +]); + +/** + * Prioritize events for subscription search results + * @param events Array of events to prioritize + * @param targetPubkey The pubkey being searched for (for n: searches only - events from this pubkey get highest priority) + * @param maxResults Maximum number of results to return + * @param ndk NDK instance for user list and community checks + * @returns Prioritized array of events + * + * Priority tiers: + * 1. Prioritized event kinds (1, 1111, 9802, 20, 21, 22, 1222, 1244, 30023, 30040, 30041) + target pubkey events (n: searches only) + * 2. Events from user's follows (if logged in) + * 3. Events from community members + * 4. All other events + */ +async function prioritizeSearchEvents( + events: NDKEvent[], + targetPubkey?: string, + maxResults: number = SEARCH_LIMITS.GENERAL_CONTENT, + ndk?: NDK +): Promise { + if (events.length === 0) { + return []; + } + + // AI-NOTE: Get user lists and community status for prioritization + let userFollowPubkeys = new Set(); + let communityMemberPubkeys = new Set(); + + // Only attempt user list and community checks if NDK is provided + if (ndk) { + try { + // Import user list functions dynamically to avoid circular dependencies + const { fetchCurrentUserLists, getPubkeysFromListKind } = await import("./user_lists.ts"); + const { checkCommunity } = await import("./community_checker.ts"); + + // Get current user's follow lists (if logged in) + const userLists = await fetchCurrentUserLists(undefined, ndk); + userFollowPubkeys = getPubkeysFromListKind(userLists, 3); // Kind 3 = follow list + + // Check community status for unique pubkeys in events (limit to prevent hanging) + const uniquePubkeys = new Set(events.map(e => e.pubkey).filter(Boolean)); + const pubkeysToCheck = Array.from(uniquePubkeys).slice(0, 20); // Limit to first 20 pubkeys + + console.log(`subscription_search: Checking community status for ${pubkeysToCheck.length} pubkeys out of ${uniquePubkeys.size} total`); + + const communityChecks = await Promise.allSettled( + pubkeysToCheck.map(async (pubkey) => { + try { + const isCommunityMember = await Promise.race([ + checkCommunity(pubkey), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Community check timeout')), 2000) + ) + ]); + return { pubkey, isCommunityMember }; + } catch (error) { + console.warn(`subscription_search: Community check failed for ${pubkey}:`, error); + return { pubkey, isCommunityMember: false }; + } + }) + ); + + // Build set of community member pubkeys + communityChecks.forEach(result => { + if (result.status === "fulfilled" && result.value.isCommunityMember) { + communityMemberPubkeys.add(result.value.pubkey); + } + }); + + console.log("subscription_search: Prioritization data loaded:", { + userFollows: userFollowPubkeys.size, + communityMembers: communityMemberPubkeys.size, + totalEvents: events.length + }); + } catch (error) { + console.warn("subscription_search: Failed to load prioritization data:", error); + } + } else { + console.log("subscription_search: No NDK provided, skipping user list and community checks"); + } + + // Separate events into priority tiers + const tier1: NDKEvent[] = []; // Events from target pubkey (n: searches only) + prioritized kinds + const tier2: NDKEvent[] = []; // Events from user's follows + const tier3: NDKEvent[] = []; // Events from community members + const tier4: NDKEvent[] = []; // All other events + + for (const event of events) { + const isFromTarget = targetPubkey && event.pubkey === targetPubkey; + const isPrioritizedKind = PRIORITIZED_EVENT_KINDS.has(event.kind || 0); + const isFromFollow = userFollowPubkeys.has(event.pubkey || ""); + const isFromCommunityMember = communityMemberPubkeys.has(event.pubkey || ""); + + // AI-NOTE: Prioritized kinds are always in tier 1 + // Target pubkey priority only applies to n: searches (when targetPubkey is provided) + if (isPrioritizedKind || isFromTarget) { + tier1.push(event); + } else if (isFromFollow) { + tier2.push(event); + } else if (isFromCommunityMember) { + tier3.push(event); + } else { + tier4.push(event); + } + } + + // Sort each tier by creation time (newest first) + tier1.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + tier2.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + tier3.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + tier4.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + + // Combine tiers in priority order, respecting the limit + const result: NDKEvent[] = []; + + // Add tier 1 events (highest priority) + result.push(...tier1); + + // Add tier 2 events (follows) if we haven't reached the limit + const remainingAfterTier1 = maxResults - result.length; + if (remainingAfterTier1 > 0) { + result.push(...tier2.slice(0, remainingAfterTier1)); + } + + // Add tier 3 events (community members) if we haven't reached the limit + const remainingAfterTier2 = maxResults - result.length; + if (remainingAfterTier2 > 0) { + result.push(...tier3.slice(0, remainingAfterTier2)); + } + + // Add tier 4 events (others) if we haven't reached the limit + const remainingAfterTier3 = maxResults - result.length; + if (remainingAfterTier3 > 0) { + result.push(...tier4.slice(0, remainingAfterTier3)); + } + + console.log("subscription_search: Event prioritization complete:", { + tier1: tier1.length, // Prioritized kinds + target pubkey (n: searches only) + tier2: tier2.length, // User follows + tier3: tier3.length, // Community members + tier4: tier4.length, // Others + total: result.length + }); + + return result; +} + /** * Filter out unwanted events from search results * @param events Array of NDKEvent to filter @@ -46,7 +208,7 @@ export async function searchBySubscription( callbacks?: SearchCallbacks, abortSignal?: AbortSignal, ): Promise { - const startTime = Date.now(); // AI-NOTE: 2025-01-08 - Track search performance + const startTime = Date.now(); // AI-NOTE: Track search performance const normalizedSearchTerm = searchTerm.toLowerCase().trim(); console.log("subscription_search: Starting search:", { @@ -60,7 +222,7 @@ export async function searchBySubscription( if (cachedResult) { console.log("subscription_search: Found cached result:", cachedResult); - // AI-NOTE: 2025-01-24 - Ensure cached events have created_at property preserved + // AI-NOTE: Ensure cached events have created_at property preserved // This fixes the "Unknown date" issue when events are retrieved from cache const eventsWithCreatedAt = cachedResult.events.map(event => { if (event && typeof event === 'object' && !event.created_at) { @@ -93,16 +255,28 @@ export async function searchBySubscription( tTagEvents: tTagEventsWithCreatedAt }; - // 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 resultWithCreatedAt; - } else { - return resultWithCreatedAt; - } + // AI-NOTE: Return cached results immediately but trigger second-order search in background + // This ensures we get fast results while still updating second-order data + console.log("subscription_search: Returning cached result immediately, triggering background second-order search"); + + // Trigger second-order search in background for all search types + if (ndk) { + // Start second-order search in background for n and d searches only + if (searchType === "n" || searchType === "d") { + console.log("subscription_search: Triggering background second-order search for cached result"); + performSecondOrderSearchInBackground( + searchType as "n" | "d", + eventsWithCreatedAt, + cachedResult.eventIds || new Set(), + cachedResult.addresses || new Set(), + ndk, + searchType === "n" ? eventsWithCreatedAt[0]?.pubkey : undefined, + callbacks + ); + } + } + + return resultWithCreatedAt; } if (!ndk) { @@ -118,7 +292,7 @@ export async function searchBySubscription( searchState.timeoutId = setTimeout(() => { console.log("subscription_search: Search timeout reached"); cleanup(); - }, TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: 2025-01-24 - Use standard timeout since cache is checked first + }, TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: Use standard timeout since cache is checked first // Check for abort signal if (abortSignal?.aborted) { @@ -140,7 +314,7 @@ export async function searchBySubscription( "relays", ); - // AI-NOTE: 2025-01-24 - Check for preloaded events first (for profile searches) + // AI-NOTE: Check for preloaded events first (for profile searches) if (searchFilter.preloadedEvents && searchFilter.preloadedEvents.length > 0) { console.log("subscription_search: Using preloaded events:", searchFilter.preloadedEvents.length); processPrimaryRelayResults( @@ -161,6 +335,27 @@ export async function searchBySubscription( normalizedSearchTerm, ); searchCache.set(searchType, normalizedSearchTerm, immediateResult); + + // AI-NOTE: For profile searches, start background second-order search even for preloaded events + if (searchType === "n") { + console.log( + "subscription_search: Profile found from preloaded events, starting background second-order search", + ); + + // Start Phase 2 in background for second-order results + searchOtherRelaysInBackground( + searchType, + searchFilter, + searchState, + ndk, + callbacks, + cleanup, + ); + + // Clear the main timeout since we're returning early + cleanup(); + } + return immediateResult; } } @@ -172,11 +367,19 @@ export async function searchBySubscription( "subscription_search: Searching primary relay with filter:", searchFilter.filter, ); - const primaryEvents = await ndk.fetchEvents( + + // Add timeout to primary relay search + const primaryEventsPromise = ndk.fetchEvents( searchFilter.filter, { closeOnEose: true }, primaryRelaySet, ); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Primary relay search timeout")), TIMEOUTS.SUBSCRIPTION_SEARCH); + }); + + const primaryEvents = await Promise.race([primaryEventsPromise, timeoutPromise]) as any; console.log( "subscription_search: Primary relay returned", @@ -205,7 +408,7 @@ export async function searchBySubscription( ); searchCache.set(searchType, normalizedSearchTerm, immediateResult); - // AI-NOTE: 2025-01-08 - For profile searches, return immediately when found + // AI-NOTE: For profile searches, return immediately when found // but still start background search for second-order results if (searchType === "n") { console.log( @@ -226,6 +429,9 @@ export async function searchBySubscription( console.log( `subscription_search: Profile search completed in ${elapsed}ms`, ); + + // Clear the main timeout since we're returning early + cleanup(); return immediateResult; } @@ -239,13 +445,15 @@ export async function searchBySubscription( cleanup, ); + // Clear the main timeout since we're returning early + cleanup(); return immediateResult; } else { console.log( "subscription_search: No results from primary relay", ); - // AI-NOTE: 2025-01-08 - For profile searches, if no results found in search relays, + // AI-NOTE: For profile searches, if no results found in search relays, // try all relays as fallback if (searchType === "n") { console.log( @@ -257,11 +465,18 @@ export async function searchBySubscription( ndk, ); try { - const fallbackEvents = await ndk.fetchEvents( + // Add timeout to fallback search + const fallbackEventsPromise = ndk.fetchEvents( searchFilter.filter, { closeOnEose: true }, allRelaySet, ); + + const fallbackTimeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Fallback search timeout")), TIMEOUTS.SUBSCRIPTION_SEARCH); + }); + + const fallbackEvents = await Promise.race([fallbackEventsPromise, fallbackTimeoutPromise]) as any; console.log( "subscription_search: Fallback search returned", @@ -293,6 +508,9 @@ export async function searchBySubscription( console.log( `subscription_search: Profile search completed in ${elapsed}ms (fallback)`, ); + + // Clear the main timeout since we're returning early + cleanup(); return fallbackResult; } } catch (fallbackError) { @@ -300,6 +518,11 @@ export async function searchBySubscription( "subscription_search: Fallback search failed:", fallbackError, ); + + // If it's a timeout error, continue to return empty result + if (fallbackError instanceof Error && fallbackError.message.includes("timeout")) { + console.log("subscription_search: Fallback search timed out, returning empty result"); + } } console.log( @@ -309,12 +532,15 @@ export async function searchBySubscription( searchType, normalizedSearchTerm, ); - // AI-NOTE: 2025-01-08 - Don't cache empty profile results as they may be due to search issues + // AI-NOTE: 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)`, ); + + // Clear the main timeout since we're returning early + cleanup(); return emptyResult; } else { console.log( @@ -327,6 +553,14 @@ export async function searchBySubscription( `subscription_search: Error searching primary relay:`, error, ); + + // If it's a timeout error, continue to Phase 2 instead of failing + if (error instanceof Error && error.message.includes("timeout")) { + console.log("subscription_search: Primary relay search timed out, continuing to Phase 2"); + } else { + // For other errors, we might want to fail the search + throw error; + } } } else { console.log( @@ -344,7 +578,7 @@ export async function searchBySubscription( cleanup, ); - // AI-NOTE: 2025-01-08 - Log performance for non-profile searches + // AI-NOTE: Log performance for non-profile searches if (searchType !== "n") { const elapsed = Date.now() - startTime; console.log( @@ -352,6 +586,8 @@ export async function searchBySubscription( ); } + // Clear the main timeout since we're completing normally + cleanup(); return result; } @@ -429,7 +665,7 @@ async function createSearchFilter( return tFilter; } case "n": { - // AI-NOTE: 2025-01-24 - Use the existing profile search functionality + // AI-NOTE: Use the existing profile search functionality // This properly handles NIP-05 lookups and name searches const { searchProfiles } = await import("./profile_search.ts"); const profileResult = await searchProfiles(normalizedSearchTerm, ndk); @@ -439,7 +675,7 @@ async function createSearchFilter( const event = new NDKEvent(ndk); event.content = JSON.stringify(profile); - // AI-NOTE: 2025-01-24 - Convert npub to hex public key for compatibility with nprofileEncode + // AI-NOTE: Convert npub to hex public key for compatibility with nprofileEncode // The profile.pubkey is an npub (bech32-encoded), but nprofileEncode expects hex-encoded public key let hexPubkey = profile.pubkey || ""; if (profile.pubkey && profile.pubkey.startsWith("npub")) { @@ -455,7 +691,7 @@ async function createSearchFilter( event.pubkey = hexPubkey; event.kind = 0; - // AI-NOTE: 2025-01-24 - Use the preserved created_at timestamp from the profile + // AI-NOTE: Use the preserved created_at timestamp from the profile // This ensures the profile cards show the actual creation date instead of "Unknown date" if ((profile as any).created_at) { event.created_at = (profile as any).created_at; @@ -474,7 +710,7 @@ async function createSearchFilter( filter: { kinds: [0], limit: 1 }, // Dummy filter subscriptionType: "profile-search", searchTerm: normalizedSearchTerm, - preloadedEvents: events, // AI-NOTE: 2025-01-24 - Pass preloaded events + preloadedEvents: events, // AI-NOTE: Pass preloaded events }; console.log("subscription_search: Created profile filter with preloaded events:", nFilter); return nFilter; @@ -489,7 +725,7 @@ async function createSearchFilter( /** * Create primary relay set for search operations - * AI-NOTE: 2025-01-24 - Updated to use all available relays to prevent search failures + * AI-NOTE: Updated to use all available relays to prevent search failures */ function createPrimaryRelaySet( searchType: SearchSubscriptionType, @@ -502,7 +738,7 @@ function createPrimaryRelaySet( poolRelays.map((r: any) => r.url), ); - // AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive search coverage + // AI-NOTE: Use ALL available relays for comprehensive search coverage // This ensures searches don't fail due to missing relays and provides maximum event discovery if (searchType === "n") { @@ -545,7 +781,7 @@ function createPrimaryRelaySet( activeRelays, }); - // AI-NOTE: 2025-01-24 - Use all pool relays instead of filtering to active relays only + // AI-NOTE: Use all pool relays instead of filtering to active relays only // This ensures we don't miss events that might be on other relays console.debug( "subscription_search: Using ALL pool relays for comprehensive search coverage:", @@ -573,7 +809,17 @@ function processPrimaryRelayResults( "events from primary relay", ); + // AI-NOTE: Apply subscription fetch limit to primary relay results + const maxEvents = SEARCH_LIMITS.SUBSCRIPTION_FETCH_LIMIT; + let processedCount = 0; + for (const event of events) { + // Check if we've reached the event limit + if (processedCount >= maxEvents) { + console.log(`subscription_search: Reached event limit of ${maxEvents} in primary relay processing`); + break; + } + // Check for abort signal if (abortSignal?.aborted) { cleanup?.(); @@ -591,6 +837,7 @@ function processPrimaryRelayResults( } else { processContentEvent(event, searchType, searchState); } + processedCount++; } catch (e) { console.warn("subscription_search: Error processing event:", e); // Invalid JSON or other error, skip @@ -598,7 +845,7 @@ function processPrimaryRelayResults( } console.log( - "subscription_search: Processed events - firstOrder:", + `subscription_search: Processed ${processedCount} events (limit: ${maxEvents}) - firstOrder:`, searchState.firstOrderEvents.length, "profiles:", searchState.foundProfiles.length, @@ -748,7 +995,7 @@ function searchOtherRelaysInBackground( callbacks?: SearchCallbacks, cleanup?: () => void, ): Promise { - // AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive search coverage + // AI-NOTE: Use ALL available relays for comprehensive search coverage // This ensures we don't miss events that might be on any available relay const otherRelays = new NDKRelaySet( new Set(Array.from(ndk.pool.relays.values())), @@ -775,7 +1022,20 @@ function searchOtherRelaysInBackground( callbacks.onSubscriptionCreated(sub); } + // AI-NOTE: Track event count to enforce subscription fetch limit + let eventCount = 0; + const maxEvents = SEARCH_LIMITS.SUBSCRIPTION_FETCH_LIMIT; + sub.on("event", (event: NDKEvent) => { + // Check if we've reached the event limit + if (eventCount >= maxEvents) { + console.log(`subscription_search: Reached event limit of ${maxEvents}, stopping event processing`); + sub.stop(); + return; + } + + eventCount++; + try { if (searchType === "n") { processProfileEvent( @@ -793,17 +1053,42 @@ function searchOtherRelaysInBackground( }); return new Promise((resolve) => { - sub.on("eose", () => { - const result = processEoseResults( - searchType, - searchState, - searchFilter, - ndk, - callbacks, - ); - searchCache.set(searchType, searchState.normalizedSearchTerm, result); - cleanup?.(); - resolve(result); + let resolved = false; + + // Add timeout to prevent hanging + const timeoutId = setTimeout(async () => { + if (!resolved) { + console.log("subscription_search: Background search timeout, resolving with current results"); + resolved = true; + sub.stop(); + const result = await processEoseResults( + searchType, + searchState, + searchFilter, + ndk, + callbacks, + ); + searchCache.set(searchType, searchState.normalizedSearchTerm, result); + cleanup?.(); + resolve(result); + } + }, TIMEOUTS.SUBSCRIPTION_SEARCH); + + sub.on("eose", async () => { + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + const result = await processEoseResults( + searchType, + searchState, + searchFilter, + ndk, + callbacks, + ); + searchCache.set(searchType, searchState.normalizedSearchTerm, result); + cleanup?.(); + resolve(result); + } }); }); } @@ -811,19 +1096,19 @@ function searchOtherRelaysInBackground( /** * Process EOSE results */ -function processEoseResults( +async function processEoseResults( searchType: SearchSubscriptionType, searchState: any, searchFilter: SearchFilter, ndk: NDK, callbacks?: SearchCallbacks, -): SearchResult { +): Promise { if (searchType === "n") { return processProfileEoseResults(searchState, searchFilter, ndk, callbacks); } else if (searchType === "d") { - return processContentEoseResults(searchState, searchType, ndk); + return await processContentEoseResults(searchState, searchType, ndk, callbacks); } else if (searchType === "t") { - return processTTagEoseResults(searchState); + return await processTTagEoseResults(searchState, ndk); } return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); @@ -857,6 +1142,10 @@ function processProfileEoseResults( .sort((a, b) => b.created_at - a.created_at) .map((x) => x.event); + // AI-NOTE: For profile searches, we don't apply prioritization to the profiles themselves + // since they are all kind 0 events and should be shown in chronological order + // However, we do pass the target pubkey to the second-order search for prioritization + // Perform second-order search for npub searches if ( searchFilter.subscriptionType === "npub-specific" || @@ -922,11 +1211,12 @@ function processProfileEoseResults( /** * Process content EOSE results */ -function processContentEoseResults( +async function processContentEoseResults( searchState: any, searchType: SearchSubscriptionType, ndk: NDK, -): SearchResult { + callbacks?: SearchCallbacks, +): Promise { if (searchState.firstOrderEvents.length === 0) { return createEmptySearchResult( searchType, @@ -946,6 +1236,19 @@ function processContentEoseResults( } const dedupedEvents = Object.values(deduped).map((x) => x.event); + // AI-NOTE: Apply prioritization to first-order events for d-tag searches + // For d-tag searches, we don't have a specific target pubkey, so we only prioritize by event kind + const prioritizedEvents = await prioritizeSearchEvents( + dedupedEvents, + undefined, // No specific target pubkey for d-tag searches + SEARCH_LIMITS.GENERAL_CONTENT, + ndk + ); + + // AI-NOTE: Attach profile data to first-order events for display + // This ensures profile pictures and other metadata are available in the UI + await attachProfileDataToEvents(prioritizedEvents, ndk); + // Perform second-order search for d-tag searches if (dedupedEvents.length > 0) { performSecondOrderSearchInBackground( @@ -954,11 +1257,13 @@ function processContentEoseResults( searchState.eventIds, searchState.eventAddresses, ndk, + undefined, // targetPubkey not needed for d-tag searches + callbacks, ); } return { - events: dedupedEvents, + events: prioritizedEvents, secondOrder: [], tTagEvents: [], eventIds: searchState.eventIds, @@ -971,13 +1276,28 @@ function processContentEoseResults( /** * Process t-tag EOSE results */ -function processTTagEoseResults(searchState: any): SearchResult { +async function processTTagEoseResults(searchState: any, ndk?: NDK): Promise { if (searchState.tTagEvents.length === 0) { return createEmptySearchResult("t", searchState.normalizedSearchTerm); } + // AI-NOTE: Apply prioritization to t-tag search results + // For t-tag searches, we don't have a specific target pubkey, so we only prioritize by event kind + const prioritizedEvents = await prioritizeSearchEvents( + searchState.tTagEvents, + undefined, // No specific target pubkey for t-tag searches + SEARCH_LIMITS.GENERAL_CONTENT, + ndk + ); + + // AI-NOTE: Attach profile data to t-tag events for display + // This ensures profile pictures and other metadata are available in the UI + if (ndk) { + await attachProfileDataToEvents(prioritizedEvents, ndk); + } + return { - events: searchState.tTagEvents, + events: prioritizedEvents, secondOrder: [], tTagEvents: [], eventIds: new Set(), @@ -1026,42 +1346,35 @@ async function performSecondOrderSearchInBackground( ); let allSecondOrderEvents: NDKEvent[] = []; - // Set a timeout for second-order search - const timeoutPromise = new Promise((_, reject) => { + // Set a timeout for the initial event fetching only + const fetchTimeoutPromise = new Promise((_, reject) => { setTimeout( - () => reject(new Error("Second-order search timeout")), + () => reject(new Error("Second-order search fetch timeout")), TIMEOUTS.SECOND_ORDER_SEARCH, ); }); - const searchPromise = (async () => { + const fetchPromise = (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); + // AI-NOTE: Use all available relays for second-order search to maximize results const relaySet = new NDKRelaySet( - new Set(availableRelays), + new Set(Array.from(ndk.pool.relays.values())), ndk, ); console.log( "subscription_search: Using", - activeRelays.length, - "active relays for second-order search", + ndk.pool.relays.size, + "relays for second-order search", ); // Search for events that mention this pubkey via p-tags - const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging + const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: Limit results to prevent hanging const pTagEvents = await ndk.fetchEvents( pTagFilter, { closeOnEose: true }, @@ -1074,8 +1387,8 @@ async function performSecondOrderSearchInBackground( 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 + // AI-NOTE: Also search for events written by this pubkey with limit + const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: Limit results to prevent hanging const authorEvents = await ndk.fetchEvents( authorFilter, { closeOnEose: true }, @@ -1141,64 +1454,114 @@ async function performSecondOrderSearchInBackground( ...filteredATagEvents, ]; } + })(); - // Deduplicate by event ID - const uniqueSecondOrder = new Map(); - allSecondOrderEvents.forEach((event) => { - if (event.id) { - uniqueSecondOrder.set(event.id, event); - } - }); + // Race between fetch and timeout - only timeout the initial event fetching + await Promise.race([fetchPromise, fetchTimeoutPromise]); + + // Now do the prioritization without timeout + console.log("subscription_search: Event fetching completed, starting prioritization..."); + + // Deduplicate by event ID + const uniqueSecondOrder = new Map(); + allSecondOrderEvents.forEach((event) => { + if (event.id) { + uniqueSecondOrder.set(event.id, event); + } + }); - let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values()); + let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values()); - // Remove any events already in first order - const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id)); - deduplicatedSecondOrder = deduplicatedSecondOrder.filter( - (e) => !firstOrderIds.has(e.id), - ); + // Remove any events already in first order + const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id)); + deduplicatedSecondOrder = deduplicatedSecondOrder.filter( + (e) => !firstOrderIds.has(e.id), + ); - // Sort by creation date (newest first) and limit to newest results - const sortedSecondOrder = deduplicatedSecondOrder - .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) - .slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); + // AI-NOTE: Apply prioritization to second-order search results with timeout + // Prioritize events from the target pubkey and specific event kinds + const prioritizationPromise = prioritizeSearchEvents( + deduplicatedSecondOrder, + targetPubkey, + SEARCH_LIMITS.SECOND_ORDER_RESULTS, + ndk + ); + + const prioritizationTimeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Prioritization timeout')), 15000); // 15 second timeout + }); + + let prioritizedSecondOrder: NDKEvent[]; + try { + prioritizedSecondOrder = await Promise.race([ + prioritizationPromise, + prioritizationTimeoutPromise + ]) as NDKEvent[]; console.log( "subscription_search: Second-order search completed with", - sortedSecondOrder.length, + prioritizedSecondOrder.length, + "prioritized results", + ); + } catch (error) { + console.warn("subscription_search: Prioritization failed, using simple sorting:", error); + // Fallback to simple sorting if prioritization fails + prioritizedSecondOrder = deduplicatedSecondOrder.sort((a, b) => { + // Prioritize events from target pubkey first (for n: searches) + if (targetPubkey) { + const aIsTarget = a.pubkey === targetPubkey; + const bIsTarget = b.pubkey === targetPubkey; + if (aIsTarget && !bIsTarget) return -1; + if (!aIsTarget && bIsTarget) return 1; + } + + // Prioritize by event kind (for t: searches and general prioritization) + const aIsPrioritized = PRIORITIZED_EVENT_KINDS.has(a.kind || 0); + const bIsPrioritized = PRIORITIZED_EVENT_KINDS.has(b.kind || 0); + if (aIsPrioritized && !bIsPrioritized) return -1; + if (!aIsPrioritized && bIsPrioritized) return 1; + + // Then sort by creation time (newest first) + return (b.created_at || 0) - (a.created_at || 0); + }).slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); + + console.log( + "subscription_search: Using fallback sorting with", + prioritizedSecondOrder.length, "results", ); + } - // Update the search results with second-order events - const result: SearchResult = { - events: firstOrderEvents, - secondOrder: sortedSecondOrder, - tTagEvents: [], - eventIds: searchType === "n" - ? new Set(firstOrderEvents.map((p) => p.id)) - : eventIds, - addresses: searchType === "n" ? new Set() : addresses, - searchType: searchType, - searchTerm: "", // This will be set by the caller - }; - - // 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", - ); - } - })(); + // AI-NOTE: Attach profile data to second-order events for display + // This ensures profile pictures and other metadata are available in the UI + await attachProfileDataToEvents(prioritizedSecondOrder, ndk); + + // Update the search results with second-order events + const result: SearchResult = { + events: firstOrderEvents, + secondOrder: prioritizedSecondOrder, + tTagEvents: [], + eventIds: searchType === "n" + ? new Set(firstOrderEvents.map((p) => p.id)) + : eventIds, + addresses: searchType === "n" ? new Set() : addresses, + searchType: searchType, + searchTerm: "", // This will be set by the caller + }; - // Race between search and timeout - await Promise.race([searchPromise, timeoutPromise]); + // Notify UI of updated results + if (callbacks?.onSecondOrderUpdate) { + console.log( + "subscription_search: Calling onSecondOrderUpdate callback with", + prioritizedSecondOrder.length, + "second-order events", + ); + callbacks.onSecondOrderUpdate(result); + } else { + console.log( + "subscription_search: No onSecondOrderUpdate callback available", + ); + } } catch (err) { console.error( `[Search] Error in second-order ${searchType}-tag search:`, @@ -1206,3 +1569,88 @@ async function performSecondOrderSearchInBackground( ); } } + +/** + * Attach profile data to events for display purposes + * This function fetches and attaches profile information to events so they can display profile pictures and other metadata + * @param events Array of events to attach profile data to + * @param ndk NDK instance for fetching profile data + * @returns Promise that resolves when profile data is attached + */ +async function attachProfileDataToEvents(events: NDKEvent[], ndk: NDK): Promise { + if (events.length === 0) { + return; + } + + console.log(`subscription_search: Attaching profile data to ${events.length} events`); + + try { + // Import user list functions dynamically to avoid circular dependencies + const { fetchCurrentUserLists, isPubkeyInUserLists } = await import("./user_lists.ts"); + + // Get current user's lists for user list status + const userLists = await fetchCurrentUserLists(undefined, ndk); + + // Get unique pubkeys from events + const uniquePubkeys = new Set(); + events.forEach((event) => { + if (event.pubkey) { + uniquePubkeys.add(event.pubkey); + } + }); + + console.log(`subscription_search: Found ${uniquePubkeys.size} unique pubkeys to fetch profiles for`); + + // Fetch profile data for each unique pubkey + const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => { + try { + // Import getUserMetadata dynamically to avoid circular dependencies + const { getUserMetadata } = await import("./nostrUtils.ts"); + const npub = await import("./nostrUtils.ts").then(m => m.toNpub(pubkey)); + + if (npub) { + const profileData = await getUserMetadata(npub, ndk, true); + if (profileData) { + // Check if this pubkey is in user's lists + const isInLists = isPubkeyInUserLists(pubkey, userLists); + + // Return profile data with user list status + return { + pubkey, + profileData: { + ...profileData, + isInUserLists: isInLists + } + }; + } + } + } catch (error) { + console.warn(`subscription_search: Failed to fetch profile for ${pubkey}:`, error); + } + return null; + }); + + const profileResults = await Promise.allSettled(profilePromises); + + // Create a map of pubkey to profile data + const profileMap = new Map(); + profileResults.forEach((result) => { + if (result.status === "fulfilled" && result.value) { + profileMap.set(result.value.pubkey, result.value.profileData); + } + }); + + console.log(`subscription_search: Successfully fetched ${profileMap.size} profiles`); + + // Attach profile data to each event + events.forEach((event) => { + if (event.pubkey && profileMap.has(event.pubkey)) { + (event as any).profileData = profileMap.get(event.pubkey); + } + }); + + console.log(`subscription_search: Profile data attachment complete`); + } catch (error) { + console.error("subscription_search: Error attaching profile data:", error); + } +} diff --git a/src/lib/utils/websocket_utils.ts b/src/lib/utils/websocket_utils.ts index 5113d1c..43d2025 100644 --- a/src/lib/utils/websocket_utils.ts +++ b/src/lib/utils/websocket_utils.ts @@ -97,7 +97,7 @@ export async function fetchNostrEvent( } } - // AI-NOTE: 2025-01-24 - Enhanced relay strategy for better event discovery + // AI-NOTE: Enhanced relay strategy for better event discovery // Always include search relays in the relay set for comprehensive event discovery const { searchRelays, secondaryRelays } = await import("../consts.ts"); const allRelays = [...availableRelays, ...searchRelays, ...secondaryRelays]; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 0612dc6..5296455 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -26,7 +26,7 @@ const rect = document.body.getBoundingClientRect(); // document.body.style.height = `${rect.height}px`; - // AI-NOTE: 2025-01-24 - Restore authentication state from localStorage on page load + // AI-NOTE: Restore authentication state from localStorage on page load // This function automatically restores the user's login state when the page is refreshed, // preventing the user from being logged out unexpectedly. It handles extension, npub, and Amber logins. async function restoreAuthentication() { diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 715caf7..daa31a2 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -3,10 +3,13 @@ import { Heading, Img, P, A } from "flowbite-svelte"; import { goto } from "$app/navigation"; import RelayStatus from "$lib/components/RelayStatus.svelte"; - + import { getNdkContext } from "$lib/ndk"; + // Get the git tag version from environment variables const appVersion = import.meta.env.APP_VERSION || "development"; const isVersionKnown = appVersion !== "development"; + + const ndk = getNdkContext();
    @@ -52,6 +55,7 @@ We are easiest to contact over our Nostr address {@render userBadge( "npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz", "GitCitadel", + ndk, )}. Or, you can visit us on our Alexandria repo page. { + console.log('Clearing all caches for testing...'); + clearAllCaches(); + console.log('Caches cleared. Try searching again to test second-order search.'); + }; + + // AI-NOTE: Add function to clear specific search cache + // Usage: window.clearSearchCache('n', 'silberengel') + (window as any).clearSearchCache = (searchType: string, searchTerm: string) => { + console.log(`Clearing search cache for ${searchType}:${searchTerm}...`); + // Import searchCache dynamically + import('$lib/utils/searchCache').then(({ searchCache }) => { + searchCache.clear(); + console.log('Search cache cleared. Try searching again to test second-order search.'); + }); + }; + } let loading = $state(false); let error = $state(null); @@ -56,21 +80,34 @@ // Get NDK context during component initialization const ndk = getNdkContext(); + // AI-NOTE: Event navigation and comment feed update issue + // When navigating to events via e-tags, the CommentViewer component may experience + // timing issues that cause comment feed problems. This function is called when + // a new event is found, and it triggers the CommentViewer to update. + // The CommentViewer has been updated with better state management to handle + // these race conditions. function handleEventFound(newEvent: NDKEvent) { event = newEvent; showSidePanel = true; // searchInProgress = false; // secondOrderSearchMessage = null; - // AI-NOTE: 2025-01-24 - Properly parse profile data for kind 0 events + // AI-NOTE: Properly parse profile data for kind 0 events if (newEvent.kind === 0) { try { const parsedProfile = parseProfileContent(newEvent); if (parsedProfile) { - profile = parsedProfile; - - // If the event doesn't have user list information, fetch it - if (typeof parsedProfile.isInUserLists !== "boolean") { + // Check if we already have user list information from the search results + const existingProfileData = (newEvent as any).profileData; + if (existingProfileData && typeof existingProfileData.isInUserLists === "boolean") { + // Use the existing user list status from search results + profile = { ...parsedProfile, isInUserLists: existingProfileData.isInUserLists } as any; + console.log(`[Events Page] Using existing user list status for ${newEvent.pubkey}: ${existingProfileData.isInUserLists}`); + } else { + // Set initial profile and fetch user list information + profile = parsedProfile; + + // Fetch user list information fetchCurrentUserLists(undefined, ndk) .then((userLists) => { const isInLists = isPubkeyInUserLists( @@ -79,11 +116,12 @@ ); // Update the profile with user list information profile = { ...parsedProfile, isInUserLists: isInLists } as any; - // Also update the event's profileData + // Also update the event's profileData for consistency (newEvent as any).profileData = { ...parsedProfile, isInUserLists: isInLists, }; + console.log(`[Events Page] Updated user list status for ${newEvent.pubkey}: ${isInLists}`); }) .catch(() => { profile = { ...parsedProfile, isInUserLists: false } as any; @@ -91,6 +129,7 @@ ...parsedProfile, isInUserLists: false, }; + console.log(`[Events Page] Set default user list status for ${newEvent.pubkey}: false`); }); } } else { @@ -108,33 +147,32 @@ profile = null; } - // AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author + // AI-NOTE: Ensure profile is cached for the event author if (newEvent.pubkey) { cacheProfileForPubkey(newEvent.pubkey); - // Update profile data with user list information - updateProfileDataWithUserLists([newEvent]); - - // Also check community status for the individual event + // Also check community status for the individual event if not already cached if (!communityStatus[newEvent.pubkey]) { checkCommunity(newEvent.pubkey) .then((status) => { communityStatus = { ...communityStatus, [newEvent.pubkey]: status }; + console.log(`[Events Page] Updated community status for ${newEvent.pubkey}: ${status}`); }) .catch(() => { communityStatus = { ...communityStatus, [newEvent.pubkey]: false }; + console.log(`[Events Page] Set default community status for ${newEvent.pubkey}: false`); }); } } } - // AI-NOTE: 2025-01-24 - Function to ensure profile is cached for a pubkey + // AI-NOTE: 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, undefined, true); + await getUserMetadata(npub, ndk, true); console.log(`[Events Page] Cached profile for pubkey: ${pubkey}`); } } catch (error) { @@ -145,32 +183,7 @@ } } - // AI-NOTE: 2025-01-24 - Function to update profile data with user list information - async function updateProfileDataWithUserLists(events: NDKEvent[]) { - try { - const userLists = await fetchCurrentUserLists(undefined, ndk); - for (const event of events) { - if (event.kind === 0 && event.pubkey) { - const existingProfileData = - (event as any).profileData || parseProfileContent(event); - - if (existingProfileData) { - const isInLists = isPubkeyInUserLists(event.pubkey, userLists); - (event as any).profileData = { - ...existingProfileData, - isInUserLists: isInLists, - }; - } - } - } - } catch (error) { - console.warn( - "[Events Page] Failed to update profile data with user lists:", - error, - ); - } - } // Use Svelte 5 idiomatic effect to update searchValue and searchType based on URL parameters $effect(() => { @@ -220,7 +233,7 @@ } }); - // AI-NOTE: 2025-01-24 - Function to ensure events have created_at property + // AI-NOTE: Function to ensure events have created_at property // This fixes the "Unknown date" issue when events are retrieved from cache function ensureEventProperties(events: NDKEvent[]): NDKEvent[] { return events.map((event) => { @@ -247,7 +260,7 @@ searchTypeParam?: string, searchTermParam?: string, ) { - // AI-NOTE: 2025-01-24 - Ensure all events have proper properties + // AI-NOTE: Ensure all events have proper properties const processedResults = ensureEventProperties(results); const processedSecondOrder = ensureEventProperties(secondOrder); const processedTTagEvents = ensureEventProperties(tTagEvents); @@ -264,7 +277,7 @@ searchInProgress = loading || (results.length > 0 && secondOrder.length === 0); - // AI-NOTE: 2025-01-08 - Only show second-order search message if we're actually searching + // AI-NOTE: 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 && @@ -298,30 +311,11 @@ checkCommunityStatusForResults(tTagEvents); } - // AI-NOTE: 2025-01-24 - Cache profiles for all search results - cacheProfilesForEvents([...results, ...secondOrder, ...tTagEvents]); + // AI-NOTE: Profile data is now handled in subscription_search.ts + // No need to cache profiles here as they're already attached to events } - // 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); - } - }); - - // Cache profiles in parallel - const cachePromises = Array.from(uniquePubkeys).map((pubkey) => - cacheProfileForPubkey(pubkey), - ); - await Promise.allSettled(cachePromises); - - // AI-NOTE: 2025-01-24 - Update profile data with user list information for cached events - await updateProfileDataWithUserLists(events); - console.log(`[Events Page] Profile caching complete`); - } function handleClear() { searchType = null; @@ -390,7 +384,7 @@ return "Reference"; } - // AI-NOTE: 2025-01-24 - Function to parse profile content from kind 0 events + // AI-NOTE: Function to parse profile content from kind 0 events function parseProfileContent(event: NDKEvent): UserProfile | null { if (event.kind !== 0 || !event.content) { return null; @@ -477,15 +471,16 @@
    -
    +
    Events
    @@ -564,7 +559,7 @@ ? "lg:block hidden" : "block"} > - + {#if searchType === "n"} Search Results for name: "{searchTerm && searchTerm.length > 50 @@ -586,63 +581,95 @@ })()}" ({searchResults.length} events) {/if} -
    +
    {#each searchResults as result, index} {@const profileData = (result as any).profileData || parseProfileContent(result)}