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

Loading comments...

+
+ {:else if error} +
+

{error}

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

No comments yet. Be the first to comment!

+
+ {:else} +
+ {#each threadedComments as node (node.event.id)} +
+
+
+
+ + {getAuthorName(node.event.pubkey)} + + + {formatDate(node.event.created_at || 0)} Kind: {node.event.kind} + +
+
+ + {shortenNevent(getNeventUrl(node.event))} + + +
+
+ +
+ {@html node.event.content || ""} +
+
+ + {#if node.children.length > 0} + {#each node.children as childNode (childNode.event.id)} +
+
+
+
+ + {getAuthorName(childNode.event.pubkey)} + + + {formatDate(childNode.event.created_at || 0)} Kind: {childNode.event.kind} + +
+
+ + {shortenNevent(getNeventUrl(childNode.event))} + + +
+
+ +
+ {@html childNode.event.content || ""} +
+
+
+ {/each} + {/if} +
+ {/each} +
+ {/if} +
\ No newline at end of file diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte index 15c469c..65e0c43 100644 --- a/src/routes/events/+page.svelte +++ b/src/routes/events/+page.svelte @@ -8,6 +8,7 @@ import EventDetails from "$lib/components/EventDetails.svelte"; import RelayActions from "$lib/components/RelayActions.svelte"; import CommentBox from "$lib/components/CommentBox.svelte"; +import CommentViewer from "$lib/components/CommentViewer.svelte"; import { userStore } from "$lib/stores/userStore"; import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils"; @@ -810,6 +811,8 @@ + + {#if isLoggedIn && userPubkey}
Add Comment From 976771fc655b74b8d23c2e6d84d494cf7c29e61b Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 07:12:42 +0200 Subject: [PATCH 04/29] working kind 1 thread --- src/lib/components/CommentViewer.svelte | 375 +++++++++++++++--------- 1 file changed, 229 insertions(+), 146 deletions(-) diff --git a/src/lib/components/CommentViewer.svelte b/src/lib/components/CommentViewer.svelte index b240aad..078341c 100644 --- a/src/lib/components/CommentViewer.svelte +++ b/src/lib/components/CommentViewer.svelte @@ -1,96 +1,129 @@ + +{#snippet CommentItem(node: CommentNode)} +
+
+
+
+ {#if getAuthorPicture(node.event.pubkey)} + {getAuthorName(node.event.pubkey)} (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else} +
+ + {getAuthorName(node.event.pubkey).charAt(0).toUpperCase()} + +
+ {/if} +
+ + {getAuthorName(node.event.pubkey)} + + + {formatRelativeDate(node.event.created_at || 0)} • Kind: {node.event.kind} + +
+
+
+ + {shortenNevent(getNeventUrl(node.event))} + + +
+
+ +
+ {#await parseContent(node.event.content || "") then parsedContent} + {@html parsedContent} + {:catch} + {@html node.event.content || ""} + {/await} +
+
+ + {#if node.children.length > 0} +
+ {#each node.children as childNode (childNode.event.id)} + {@render CommentItem(childNode)} + {/each} +
+ {/if} +
+{/snippet} +
Comments ({threadedComments.length}) @@ -239,77 +392,7 @@ {:else}
{#each threadedComments as node (node.event.id)} -
-
-
-
- - {getAuthorName(node.event.pubkey)} - - - {formatDate(node.event.created_at || 0)} Kind: {node.event.kind} - -
-
- - {shortenNevent(getNeventUrl(node.event))} - - -
-
- -
- {@html node.event.content || ""} -
-
- - {#if node.children.length > 0} - {#each node.children as childNode (childNode.event.id)} -
-
-
-
- - {getAuthorName(childNode.event.pubkey)} - - - {formatDate(childNode.event.created_at || 0)} Kind: {childNode.event.kind} - -
-
- - {shortenNevent(getNeventUrl(childNode.event))} - - -
-
- -
- {@html childNode.event.content || ""} -
-
-
- {/each} - {/if} -
+ {@render CommentItem(node)} {/each}
{/if} From 550ae84a9f81db09c82624dd6f99f90cb6910c59 Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 07:17:22 +0200 Subject: [PATCH 05/29] fixed commentbox userbadge display --- src/lib/components/CommentBox.svelte | 31 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/lib/components/CommentBox.svelte b/src/lib/components/CommentBox.svelte index fbff0f3..6952279 100644 --- a/src/lib/components/CommentBox.svelte +++ b/src/lib/components/CommentBox.svelte @@ -10,6 +10,7 @@ } from "$lib/utils/search_utility"; import { userPubkey } from "$lib/stores/authStore.Svelte"; + import { userStore } from "$lib/stores/userStore"; import type { NDKEvent } from "$lib/utils/nostrUtils"; import { extractRootEventInfo, @@ -67,17 +68,12 @@ } }); + // Get user profile from userStore $effect(() => { - const trimmedPubkey = $userPubkey?.trim(); - const npub = toNpub(trimmedPubkey); - if (npub) { - // Call an async function, but don't make the effect itself async - getUserMetadata(npub).then((metadata) => { - userProfile = metadata; - }); - } else if (trimmedPubkey) { - userProfile = null; - error = "Invalid public key: must be a 64-character hex string."; + const currentUser = $userStore; + if (currentUser?.signedIn && currentUser.profile) { + userProfile = currentUser.profile; + error = null; } else { userProfile = null; error = null; @@ -590,17 +586,20 @@ {userProfile.name { - const img = e.target as HTMLImageElement; - img.src = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(img.alt)}`; - }} + class="w-8 h-8 rounded-full object-cover" + onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'} /> + {:else} +
+ + {(userProfile.displayName || userProfile.name || "U").charAt(0).toUpperCase()} + +
{/if} {userProfile.displayName || userProfile.name || - nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."} + `${$userPubkey?.slice(0, 8)}...${$userPubkey?.slice(-4)}`}
{/if} From 939759e5ceea3b4f21b2966ac9f60130b79054d5 Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 07:41:23 +0200 Subject: [PATCH 06/29] made the cache more persistent --- src/lib/utils/npubCache.ts | 44 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/lib/utils/npubCache.ts b/src/lib/utils/npubCache.ts index 4fc4405..8c1c36f 100644 --- a/src/lib/utils/npubCache.ts +++ b/src/lib/utils/npubCache.ts @@ -4,6 +4,47 @@ export type NpubMetadata = NostrProfile; class NpubCache { private cache: Record = {}; + private readonly storageKey = 'alexandria_npub_cache'; + private readonly maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + + constructor() { + this.loadFromStorage(); + } + + private loadFromStorage(): void { + try { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(this.storageKey); + if (stored) { + const data = JSON.parse(stored) as Record; + const now = Date.now(); + + // Filter out expired entries + for (const [key, entry] of Object.entries(data)) { + if (entry.timestamp && (now - entry.timestamp) < this.maxAge) { + this.cache[key] = entry.profile; + } + } + } + } + } catch (error) { + console.warn('Failed to load npub cache from storage:', error); + } + } + + private saveToStorage(): void { + try { + if (typeof window !== 'undefined') { + const data: Record = {}; + for (const [key, profile] of Object.entries(this.cache)) { + data[key] = { profile, timestamp: Date.now() }; + } + localStorage.setItem(this.storageKey, JSON.stringify(data)); + } + } catch (error) { + console.warn('Failed to save npub cache to storage:', error); + } + } get(key: string): NpubMetadata | undefined { return this.cache[key]; @@ -11,6 +52,7 @@ class NpubCache { set(key: string, value: NpubMetadata): void { this.cache[key] = value; + this.saveToStorage(); } has(key: string): boolean { @@ -20,6 +62,7 @@ class NpubCache { delete(key: string): boolean { if (key in this.cache) { delete this.cache[key]; + this.saveToStorage(); return true; } return false; @@ -37,6 +80,7 @@ class NpubCache { clear(): void { this.cache = {}; + this.saveToStorage(); } size(): number { From 485601a67b1ac08aa5c20405209c60f447e168ea Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 07:41:58 +0200 Subject: [PATCH 07/29] fixed image display for markup content fields --- src/lib/utils/markup/basicMarkupParser.ts | 45 ++++++++++++++--------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/lib/utils/markup/basicMarkupParser.ts b/src/lib/utils/markup/basicMarkupParser.ts index fd7fd14..2d70c41 100644 --- a/src/lib/utils/markup/basicMarkupParser.ts +++ b/src/lib/utils/markup/basicMarkupParser.ts @@ -78,20 +78,7 @@ function replaceAlexandriaNostrLinks(text: string): string { return `nostr:${bech32Match[0]}`; } } - // For non-Alexandria/localhost URLs, append (View here: nostr:) if a Nostr identifier is present - const hexMatch = url.match(hexPattern); - if (hexMatch) { - try { - const nevent = nip19.neventEncode({ id: hexMatch[0] }); - return `${url} (View here: nostr:${nevent})`; - } catch { - return url; - } - } - const bech32Match = url.match(bech32Pattern); - if (bech32Match) { - return `${url} (View here: nostr:${bech32Match[0]})`; - } + // For non-Alexandria/localhost URLs, just return the URL as-is return url; }); @@ -253,7 +240,18 @@ function processBasicFormatting(content: string): string { } // Only render if the url ends with a direct image extension if (IMAGE_EXTENSIONS.test(url.split("?")[0])) { - return `${alt}`; + return `
+
+
+
🖼️
+
Image
+
+
+ ${alt} + +
`; } // Otherwise, render as a clickable link return `${alt || url}`; @@ -290,7 +288,18 @@ function processBasicFormatting(content: string): string { } // Only render if the url ends with a direct image extension if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) { - return `Embedded media`; + return `
+
+
+
🖼️
+
Image
+
+
+ Embedded media + +
`; } // Otherwise, render as a clickable link return `${clean}`; @@ -310,10 +319,10 @@ function processBasicFormatting(content: string): string { }, ); - // Process hashtags + // Process hashtags as clickable buttons processedText = processedText.replace( HASHTAG_REGEX, - '#$1', + '', ); // --- Improved List Grouping and Parsing --- From 577d8c832a7b33c9a7100585abc009db5d69b876 Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 08:31:46 +0200 Subject: [PATCH 08/29] fix relays and subscription search --- src/lib/components/CommentViewer.svelte | 46 ++--- src/lib/ndk.ts | 88 +++++++++- src/lib/utils/subscription_search.ts | 217 ++++++++++++++++++++---- src/routes/+layout.svelte | 24 ++- src/routes/events/+page.svelte | 27 ++- 5 files changed, 333 insertions(+), 69 deletions(-) diff --git a/src/lib/components/CommentViewer.svelte b/src/lib/components/CommentViewer.svelte index 078341c..20ace2c 100644 --- a/src/lib/components/CommentViewer.svelte +++ b/src/lib/components/CommentViewer.svelte @@ -294,12 +294,6 @@ let parsedContent = await parseBasicmarkup(content); - // Make images blurry until clicked - parsedContent = parsedContent.replace( - /]+)>/g, - '' - ); - return parsedContent; } @@ -313,24 +307,32 @@ >
- {#if getAuthorPicture(node.event.pubkey)} - {getAuthorName(node.event.pubkey)} (e.target as HTMLImageElement).style.display = 'none'} - /> - {:else} -
- - {getAuthorName(node.event.pubkey).charAt(0).toUpperCase()} - -
- {/if} +
- + ([]); export const activeInboxRelays = writable([]); export const activeOutboxRelays = writable([]); +// AI-NOTE: 2025-01-08 - Persistent relay storage to avoid recalculation +let persistentRelaySet: { inboxRelays: string[]; outboxRelays: string[] } | null = null; +let relaySetLastUpdated: number = 0; +const RELAY_SET_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes +const RELAY_SET_STORAGE_KEY = 'alexandria/relay_set_cache'; + +/** + * Load persistent relay set from localStorage + */ +function loadPersistentRelaySet(): { relaySet: { inboxRelays: string[]; outboxRelays: string[] } | null; lastUpdated: number } { + try { + const stored = localStorage.getItem(RELAY_SET_STORAGE_KEY); + if (!stored) return { relaySet: null, lastUpdated: 0 }; + + const data = JSON.parse(stored); + const now = Date.now(); + + // Check if cache is expired + if (now - data.timestamp > RELAY_SET_CACHE_DURATION) { + localStorage.removeItem(RELAY_SET_STORAGE_KEY); + return { relaySet: null, lastUpdated: 0 }; + } + + return { relaySet: data.relaySet, lastUpdated: data.timestamp }; + } catch (error) { + console.warn('[NDK.ts] Failed to load persistent relay set:', error); + localStorage.removeItem(RELAY_SET_STORAGE_KEY); + return { relaySet: null, lastUpdated: 0 }; + } +} + +/** + * Save persistent relay set to localStorage + */ +function savePersistentRelaySet(relaySet: { inboxRelays: string[]; outboxRelays: string[] }): void { + try { + const data = { + relaySet, + timestamp: Date.now() + }; + localStorage.setItem(RELAY_SET_STORAGE_KEY, JSON.stringify(data)); + } catch (error) { + console.warn('[NDK.ts] Failed to save persistent relay set:', error); + } +} + +/** + * Clear persistent relay set from localStorage + */ +function clearPersistentRelaySet(): void { + try { + localStorage.removeItem(RELAY_SET_STORAGE_KEY); + } catch (error) { + console.warn('[NDK.ts] Failed to clear persistent relay set:', error); + } +} + // Subscribe to userStore changes and update ndkSignedIn accordingly userStore.subscribe((userState) => { ndkSignedIn.set(userState.signedIn); @@ -351,15 +408,39 @@ export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string /** * Updates the active relay stores and NDK pool with new relay URLs * @param ndk NDK instance + * @param forceUpdate Force update even if cached (default: false) */ -export async function updateActiveRelayStores(ndk: NDK): Promise { +export async function updateActiveRelayStores(ndk: NDK, forceUpdate: boolean = false): Promise { try { + // AI-NOTE: 2025-01-08 - Use persistent relay set to avoid recalculation + const now = Date.now(); + const cacheExpired = now - relaySetLastUpdated > RELAY_SET_CACHE_DURATION; + + // Load from persistent storage if not already loaded + if (!persistentRelaySet) { + const loaded = loadPersistentRelaySet(); + persistentRelaySet = loaded.relaySet; + relaySetLastUpdated = loaded.lastUpdated; + } + + if (!forceUpdate && persistentRelaySet && !cacheExpired) { + console.debug('[NDK.ts] updateActiveRelayStores: Using cached relay set'); + activeInboxRelays.set(persistentRelaySet.inboxRelays); + activeOutboxRelays.set(persistentRelaySet.outboxRelays); + return; + } + console.debug('[NDK.ts] updateActiveRelayStores: Starting relay store update'); // Get the active relay set from the relay management system const relaySet = await getActiveRelaySet(ndk); console.debug('[NDK.ts] updateActiveRelayStores: Got relay set:', relaySet); + // Cache the relay set + persistentRelaySet = relaySet; + relaySetLastUpdated = now; + savePersistentRelaySet(relaySet); // Save to persistent storage + // Update the stores with the new relay configuration activeInboxRelays.set(relaySet.inboxRelays); activeOutboxRelays.set(relaySet.outboxRelays); @@ -560,6 +641,11 @@ export function logout(user: NDKUser): void { activeInboxRelays.set([]); activeOutboxRelays.set([]); + // AI-NOTE: 2025-01-08 - Clear persistent relay set on logout + persistentRelaySet = null; + relaySetLastUpdated = 0; + clearPersistentRelaySet(); // Clear persistent storage + // Stop network monitoring stopNetworkStatusMonitoring(); diff --git a/src/lib/utils/subscription_search.ts b/src/lib/utils/subscription_search.ts index 17fa093..d992f5b 100644 --- a/src/lib/utils/subscription_search.ts +++ b/src/lib/utils/subscription_search.ts @@ -26,6 +26,17 @@ const normalizeUrl = (url: string): string => { return url.replace(/\/$/, ''); // Remove trailing slash }; +/** + * Filter out unwanted events from search results + * @param events Array of NDKEvent to filter + * @returns Filtered array of NDKEvent + */ +function filterUnwantedEvents(events: NDKEvent[]): NDKEvent[] { + return events.filter( + (event) => !isEmojiReaction(event) && event.kind !== 3 && event.kind !== 5, + ); +} + /** * Search for events by subscription type (d, t, n) */ @@ -35,6 +46,7 @@ export async function searchBySubscription( callbacks?: SearchCallbacks, abortSignal?: AbortSignal, ): Promise { + const startTime = Date.now(); // AI-NOTE: 2025-01-08 - Track search performance const normalizedSearchTerm = searchTerm.toLowerCase().trim(); console.log("subscription_search: Starting search:", { @@ -47,7 +59,22 @@ export async function searchBySubscription( const cachedResult = searchCache.get(searchType, normalizedSearchTerm); if (cachedResult) { console.log("subscription_search: Found cached result:", cachedResult); - return cachedResult; + // AI-NOTE: 2025-01-08 - For profile searches, clear cache if it's empty to force fresh search + if (searchType === "n" && cachedResult.events.length === 0) { + console.log("subscription_search: Clearing empty cached profile result to force fresh search"); + searchCache.clear(); // Clear all cache to force fresh search + } else if (searchType === "n" && cachedResult.events.length > 0 && cachedResult.secondOrder.length === 0) { + // AI-NOTE: 2025-01-08 - Clear cache if we have profile results but no second-order events + // This forces a fresh search that includes second-order searching + console.log("subscription_search: Clearing cached profile result with no second-order events to force fresh search"); + searchCache.clear(); + } else if (searchType === "n") { + // AI-NOTE: 2025-01-08 - For profile searches, always clear cache to ensure fresh second-order search + console.log("subscription_search: Clearing cache for profile search to ensure fresh second-order search"); + searchCache.clear(); + } else { + return cachedResult; + } } const ndk = get(ndkInstance); @@ -64,7 +91,7 @@ export async function searchBySubscription( searchState.timeoutId = setTimeout(() => { console.log("subscription_search: Search timeout reached"); cleanup(); - }, TIMEOUTS.SUBSCRIPTION_SEARCH); + }, searchType === "n" ? 5000 : TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: 2025-01-08 - Shorter timeout for profile searches // Check for abort signal if (abortSignal?.aborted) { @@ -125,7 +152,26 @@ export async function searchBySubscription( ); searchCache.set(searchType, normalizedSearchTerm, immediateResult); - // Start Phase 2 in background for additional results + // AI-NOTE: 2025-01-08 - For profile searches, return immediately when found + // but still start background search for second-order results + if (searchType === "n") { + console.log("subscription_search: Profile found, returning immediately but starting background second-order search"); + + // Start Phase 2 in background for second-order results + searchOtherRelaysInBackground( + searchType, + searchFilter, + searchState, + callbacks, + cleanup, + ); + + const elapsed = Date.now() - startTime; + console.log(`subscription_search: Profile search completed in ${elapsed}ms`); + return immediateResult; + } + + // Start Phase 2 in background for additional results (only for non-profile searches) searchOtherRelaysInBackground( searchType, searchFilter, @@ -137,8 +183,72 @@ export async function searchBySubscription( return immediateResult; } else { console.log( - "subscription_search: No results from primary relay, continuing to Phase 2", + "subscription_search: No results from primary relay", ); + + // AI-NOTE: 2025-01-08 - For profile searches, if no results found in search relays, + // try all relays as fallback + if (searchType === "n") { + console.log( + "subscription_search: No profile found in search relays, trying all relays", + ); + // Try with all relays as fallback + const allRelaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())) as any, ndk); + try { + const fallbackEvents = await ndk.fetchEvents( + searchFilter.filter, + { closeOnEose: true }, + allRelaySet, + ); + + console.log( + "subscription_search: Fallback search returned", + fallbackEvents.size, + "events", + ); + + processPrimaryRelayResults( + fallbackEvents, + searchType, + searchFilter.subscriptionType, + normalizedSearchTerm, + searchState, + abortSignal, + cleanup, + ); + + if (hasResults(searchState, searchType)) { + console.log( + "subscription_search: Found profile in fallback search, returning immediately", + ); + const fallbackResult = createSearchResult( + searchState, + searchType, + normalizedSearchTerm, + ); + searchCache.set(searchType, normalizedSearchTerm, fallbackResult); + const elapsed = Date.now() - startTime; + console.log(`subscription_search: Profile search completed in ${elapsed}ms (fallback)`); + return fallbackResult; + } + } catch (fallbackError) { + console.error("subscription_search: Fallback search failed:", fallbackError); + } + + console.log( + "subscription_search: Profile not found in any relays, returning empty result", + ); + const emptyResult = createEmptySearchResult(searchType, normalizedSearchTerm); + // AI-NOTE: 2025-01-08 - Don't cache empty profile results as they may be due to search issues + // rather than the profile not existing + const elapsed = Date.now() - startTime; + console.log(`subscription_search: Profile search completed in ${elapsed}ms (not found)`); + return emptyResult; + } else { + console.log( + "subscription_search: No results from primary relay, continuing to Phase 2", + ); + } } } catch (error) { console.error( @@ -153,13 +263,21 @@ export async function searchBySubscription( } // Always do Phase 2: Search all other relays in parallel - return searchOtherRelaysInBackground( + const result = await searchOtherRelaysInBackground( searchType, searchFilter, searchState, callbacks, cleanup, ); + + // AI-NOTE: 2025-01-08 - Log performance for non-profile searches + if (searchType !== "n") { + const elapsed = Date.now() - startTime; + console.log(`subscription_search: ${searchType} search completed in ${elapsed}ms`); + } + + return result; } /** @@ -253,7 +371,7 @@ async function createProfileSearchFilter( filter: { kinds: [0], authors: [decoded.data], - limit: SEARCH_LIMITS.SPECIFIC_PROFILE, + limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search }, subscriptionType: "npub-specific", }; @@ -273,7 +391,7 @@ async function createProfileSearchFilter( filter: { kinds: [0], authors: [npub], - limit: SEARCH_LIMITS.SPECIFIC_PROFILE, + limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search }, subscriptionType: "nip05-found", }; @@ -299,31 +417,38 @@ function createPrimaryRelaySet( searchType: SearchSubscriptionType, ndk: any, ): NDKRelaySet { - // Use the new relay management system - const searchRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; - console.debug('subscription_search: Active relay stores:', { - inboxRelays: get(activeInboxRelays), - outboxRelays: get(activeOutboxRelays), - searchRelays - }); - // Debug: Log all relays in NDK pool const poolRelays = Array.from(ndk.pool.relays.values()); console.debug('subscription_search: NDK pool relays:', poolRelays.map((r: any) => r.url)); if (searchType === "n") { - // For profile searches, use search relays first - const profileRelaySet = poolRelays.filter( + // AI-NOTE: 2025-01-08 - For profile searches, prioritize search relays for speed + // Use search relays first, then fall back to all relays if needed + const searchRelaySet = poolRelays.filter( (relay: any) => searchRelays.some( (searchRelay: string) => normalizeUrl(relay.url) === normalizeUrl(searchRelay), ), ); - console.debug('subscription_search: Profile relay set:', profileRelaySet.map((r: any) => r.url)); - return new NDKRelaySet(new Set(profileRelaySet) as any, ndk); + + if (searchRelaySet.length > 0) { + console.debug('subscription_search: Profile search - using search relays for speed:', searchRelaySet.map((r: any) => r.url)); + return new NDKRelaySet(new Set(searchRelaySet) as any, ndk); + } else { + // Fallback to all relays if search relays not available + console.debug('subscription_search: Profile search - fallback to all relays:', poolRelays.map((r: any) => r.url)); + return new NDKRelaySet(new Set(poolRelays) as any, ndk); + } } else { // For other searches, use active relays first + const searchRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; + console.debug('subscription_search: Active relay stores:', { + inboxRelays: get(activeInboxRelays), + outboxRelays: get(activeOutboxRelays), + searchRelays + }); + const activeRelaySet = poolRelays.filter( (relay: any) => searchRelays.some( @@ -534,11 +659,9 @@ function searchOtherRelaysInBackground( new Set( Array.from(ndk.pool.relays.values()).filter((relay: any) => { if (searchType === "n") { - // For profile searches, exclude search relays from fallback search - return !searchRelays.some( - (searchRelay: string) => - normalizeUrl(relay.url) === normalizeUrl(searchRelay), - ); + // AI-NOTE: 2025-01-08 - For profile searches, use ALL available relays + // Don't exclude any relays since we want maximum coverage + return true; } else { // For other searches, exclude community relays from fallback search return !communityRelays.some( @@ -652,6 +775,7 @@ function processProfileEoseResults( ) { const targetPubkey = dedupedProfiles[0]?.pubkey; if (targetPubkey) { + console.log("subscription_search: Triggering second-order search for npub-specific profile:", targetPubkey); performSecondOrderSearchInBackground( "n", dedupedProfiles, @@ -660,11 +784,14 @@ function processProfileEoseResults( targetPubkey, callbacks, ); + } else { + console.log("subscription_search: No targetPubkey found for second-order search"); } } else if (searchFilter.subscriptionType === "profile") { // For general profile searches, perform second-order search for each found profile for (const profile of dedupedProfiles) { if (profile.pubkey) { + console.log("subscription_search: Triggering second-order search for general profile:", profile.pubkey); performSecondOrderSearchInBackground( "n", dedupedProfiles, @@ -675,6 +802,8 @@ function processProfileEoseResults( ); } } + } else { + console.log("subscription_search: No second-order search triggered for subscription type:", searchFilter.subscriptionType); } return { @@ -784,6 +913,7 @@ async function performSecondOrderSearchInBackground( callbacks?: SearchCallbacks, ) { try { + console.log("subscription_search: Starting second-order search for", searchType, "with targetPubkey:", targetPubkey); const ndk = get(ndkInstance); let allSecondOrderEvents: NDKEvent[] = []; @@ -797,6 +927,8 @@ async function performSecondOrderSearchInBackground( const searchPromise = (async () => { if (searchType === "n" && targetPubkey) { + console.log("subscription_search: Searching for events mentioning pubkey:", targetPubkey); + // Search for events that mention this pubkey via p-tags const pTagFilter = { "#p": [targetPubkey] }; const pTagEvents = await ndk.fetchEvents( @@ -804,11 +936,25 @@ async function performSecondOrderSearchInBackground( { closeOnEose: true }, new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), ); - // Filter out emoji reactions - const filteredEvents = Array.from(pTagEvents).filter( - (event) => !isEmojiReaction(event), + console.log("subscription_search: Found", pTagEvents.size, "events with p-tag for", targetPubkey); + + // AI-NOTE: 2025-01-08 - Also search for events written by this pubkey + const authorFilter = { authors: [targetPubkey] }; + const authorEvents = await ndk.fetchEvents( + authorFilter, + { closeOnEose: true }, + new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), ); - allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents]; + console.log("subscription_search: Found", authorEvents.size, "events written by", targetPubkey); + + // Filter out unwanted events from both sets + const filteredPTagEvents = filterUnwantedEvents(Array.from(pTagEvents)); + const filteredAuthorEvents = filterUnwantedEvents(Array.from(authorEvents)); + + console.log("subscription_search: After filtering unwanted events:", filteredPTagEvents.length, "p-tag events,", filteredAuthorEvents.length, "author events"); + + // Combine both sets of events + allSecondOrderEvents = [...filteredPTagEvents, ...filteredAuthorEvents]; } else if (searchType === "d") { // Parallel fetch for #e and #a tag events const relaySet = new NDKRelaySet( @@ -831,13 +977,9 @@ async function performSecondOrderSearchInBackground( ) : Promise.resolve([]), ]); - // Filter out emoji reactions - const filteredETagEvents = Array.from(eTagEvents).filter( - (event) => !isEmojiReaction(event), - ); - const filteredATagEvents = Array.from(aTagEvents).filter( - (event) => !isEmojiReaction(event), - ); + // Filter out unwanted events + const filteredETagEvents = filterUnwantedEvents(Array.from(eTagEvents)); + const filteredATagEvents = filterUnwantedEvents(Array.from(aTagEvents)); allSecondOrderEvents = [ ...allSecondOrderEvents, ...filteredETagEvents, @@ -866,6 +1008,8 @@ async function performSecondOrderSearchInBackground( .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); + console.log("subscription_search: Second-order search completed with", sortedSecondOrder.length, "results"); + // Update the search results with second-order events const result: SearchResult = { events: firstOrderEvents, @@ -882,7 +1026,10 @@ async function performSecondOrderSearchInBackground( // Notify UI of updated results if (callbacks?.onSecondOrderUpdate) { + console.log("subscription_search: Calling onSecondOrderUpdate callback with", sortedSecondOrder.length, "second-order events"); callbacks.onSecondOrderUpdate(result); + } else { + console.log("subscription_search: No onSecondOrderUpdate callback available"); } })(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2fff8a9..1ae83af 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,7 +5,10 @@ import { page } from "$app/stores"; import { Alert } from "flowbite-svelte"; import { HammerSolid } from "flowbite-svelte-icons"; - import { logCurrentRelayConfiguration } from "$lib/ndk"; + import { logCurrentRelayConfiguration, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; + + // Define children prop for Svelte 5 + let { children } = $props(); // Get standard metadata for OpenGraph tags let title = "Library of Alexandria"; @@ -16,12 +19,23 @@ let summary = "Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages."; + // Reactive effect to log relay configuration when stores change + $effect(() => { + const inboxRelays = $activeInboxRelays; + const outboxRelays = $activeOutboxRelays; + + // Only log if we have relays (not empty arrays) + if (inboxRelays.length > 0 || outboxRelays.length > 0) { + console.log('🔌 Relay Configuration Updated:'); + console.log('📥 Inbox Relays:', inboxRelays); + console.log('📤 Outbox Relays:', outboxRelays); + console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); + } + }); + onMount(() => { const rect = document.body.getBoundingClientRect(); // document.body.style.height = `${rect.height}px`; - - // Log relay configuration when layout mounts - logCurrentRelayConfiguration(); }); @@ -47,5 +61,5 @@
- + {@render children()}
diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte index 65e0c43..b06ea4e 100644 --- a/src/routes/events/+page.svelte +++ b/src/routes/events/+page.svelte @@ -151,21 +151,27 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; searchInProgress = loading || (results.length > 0 && secondOrder.length === 0); - // Show second-order search message when we have first-order results but no second-order yet + // AI-NOTE: 2025-01-08 - Only show second-order search message if we're actually searching + // Don't show it for cached results that have no second-order events if ( results.length > 0 && secondOrder.length === 0 && - searchTypeParam === "n" + searchTypeParam === "n" && + !loading // Only show message if we're actively searching, not for cached results ) { secondOrderSearchMessage = `Found ${results.length} profile(s). Starting second-order search for events mentioning these profiles...`; } else if ( results.length > 0 && secondOrder.length === 0 && - searchTypeParam === "d" + searchTypeParam === "d" && + !loading // Only show message if we're actively searching, not for cached results ) { secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`; } else if (secondOrder.length > 0) { secondOrderSearchMessage = null; + } else { + // Clear message if we have results but no second-order search is happening + secondOrderSearchMessage = null; } // Check community status for all search results @@ -347,9 +353,18 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; - // Log relay configuration when page mounts - onMount(() => { - logCurrentRelayConfiguration(); + // Reactive effect to log relay configuration when stores change + $effect(() => { + const inboxRelays = $activeInboxRelays; + const outboxRelays = $activeOutboxRelays; + + // Only log if we have relays (not empty arrays) + if (inboxRelays.length > 0 || outboxRelays.length > 0) { + console.log('🔌 Events Page - Relay Configuration Updated:'); + console.log('📥 Inbox Relays:', inboxRelays); + console.log('📤 Outbox Relays:', outboxRelays); + console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); + } }); From 3eafdf0282f7a7bcf0380b45dce4bb43268f4d8e Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 08:51:49 +0200 Subject: [PATCH 09/29] fixed profile display --- src/lib/components/CommentViewer.svelte | 134 ++++++- src/lib/components/EventDetails.svelte | 28 +- src/routes/events/+page.svelte | 491 +++++++++++++++--------- 3 files changed, 457 insertions(+), 196 deletions(-) diff --git a/src/lib/components/CommentViewer.svelte b/src/lib/components/CommentViewer.svelte index 20ace2c..fb819e1 100644 --- a/src/lib/components/CommentViewer.svelte +++ b/src/lib/components/CommentViewer.svelte @@ -36,12 +36,24 @@ const npub = toNpub(pubkey); if (!npub) return; - const profile = await getUserMetadata(npub); + // Force fetch to ensure we get the latest profile data + const profile = await getUserMetadata(npub, true); const newProfiles = new Map(profiles); newProfiles.set(pubkey, profile); profiles = newProfiles; + + console.log(`[CommentViewer] Fetched profile for ${pubkey}:`, profile); } catch (err) { console.warn(`Failed to fetch profile for ${pubkey}:`, err); + // Set a fallback profile to avoid repeated failed requests + const fallbackProfile = { + name: `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`, + displayName: `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`, + picture: null + }; + const newProfiles = new Map(profiles); + newProfiles.set(pubkey, fallbackProfile); + profiles = newProfiles; } } @@ -54,6 +66,9 @@ comments = []; console.log(`[CommentViewer] Fetching comments for event: ${event.id}`); + console.log(`[CommentViewer] Event kind: ${event.kind}`); + console.log(`[CommentViewer] Event pubkey: ${event.pubkey}`); + console.log(`[CommentViewer] Available relays: ${$activeInboxRelays.length}`); // Wait for relays to be available let attempts = 0; @@ -69,12 +84,35 @@ } try { - activeSub = $ndkInstance.subscribe({ - kinds: [1, 1111], - "#e": [event.id], - }); + // Try multiple filter approaches to find comments + const filters = [ + // Standard comment filter + { + kinds: [1, 1111], + "#e": [event.id], + }, + // Broader search for any events that might reference this event + { + kinds: [1, 1111], + "#e": [event.id], + limit: 100, + }, + // Search for events by the same author that might be replies + { + kinds: [1, 1111], + authors: [event.pubkey], + since: event.created_at ? event.created_at - 86400 : undefined, // Last 24 hours + limit: 50, + } + ]; + + console.log(`[CommentViewer] Setting up subscription with filters:`, filters); + + // Try the first filter (standard comment search) + activeSub = $ndkInstance.subscribe(filters[0]); const timeout = setTimeout(() => { + console.log(`[CommentViewer] Subscription timeout - no comments found`); if (activeSub) { activeSub.stop(); activeSub = null; @@ -84,8 +122,21 @@ activeSub.on("event", (commentEvent: NDKEvent) => { console.log(`[CommentViewer] Received comment: ${commentEvent.id}`); - comments = [...comments, commentEvent]; - fetchProfile(commentEvent.pubkey); + console.log(`[CommentViewer] Comment kind: ${commentEvent.kind}`); + console.log(`[CommentViewer] Comment pubkey: ${commentEvent.pubkey}`); + console.log(`[CommentViewer] Comment content preview: ${commentEvent.content?.slice(0, 100)}...`); + + // Check if this event actually references our target event + const eTags = commentEvent.getMatchingTags("e"); + const referencesTarget = eTags.some(tag => tag[1] === event.id); + + if (referencesTarget) { + console.log(`[CommentViewer] Comment references target event - adding to comments`); + comments = [...comments, commentEvent]; + fetchProfile(commentEvent.pubkey); + } else { + console.log(`[CommentViewer] Comment does not reference target event - skipping`); + } }); activeSub.on("eose", () => { @@ -96,6 +147,14 @@ activeSub = null; } loading = false; + + // Pre-fetch all profiles after comments are loaded + preFetchAllProfiles(); + + // AI-NOTE: 2025-01-24 - Test for comments if none were found + if (comments.length === 0) { + testForComments(); + } }); activeSub.on("error", (err: any) => { @@ -116,6 +175,60 @@ } } + // Pre-fetch all profiles for comments + async function preFetchAllProfiles() { + const uniquePubkeys = new Set(); + comments.forEach(comment => { + if (comment.pubkey && !profiles.has(comment.pubkey)) { + uniquePubkeys.add(comment.pubkey); + } + }); + + console.log(`[CommentViewer] Pre-fetching ${uniquePubkeys.size} profiles`); + + // Fetch profiles in parallel + const profilePromises = Array.from(uniquePubkeys).map(pubkey => fetchProfile(pubkey)); + await Promise.allSettled(profilePromises); + + console.log(`[CommentViewer] Pre-fetching complete`); + } + + // AI-NOTE: 2025-01-24 - Function to manually test for comments + async function testForComments() { + if (!event?.id) return; + + console.log(`[CommentViewer] Testing for comments on event: ${event.id}`); + + try { + // Try a broader search to see if there are any events that might be comments + const testSub = $ndkInstance.subscribe({ + kinds: [1, 1111], + "#e": [event.id], + limit: 10, + }); + + let testComments = 0; + + testSub.on("event", (testEvent: NDKEvent) => { + testComments++; + console.log(`[CommentViewer] Test found event: ${testEvent.id}, kind: ${testEvent.kind}`); + }); + + testSub.on("eose", () => { + console.log(`[CommentViewer] Test search found ${testComments} potential comments`); + testSub.stop(); + }); + + // Stop the test after 5 seconds + setTimeout(() => { + testSub.stop(); + }, 5000); + + } catch (err) { + console.error(`[CommentViewer] Test search error:`, err); + } + } + // Build threaded comment structure function buildCommentThread(events: NDKEvent[]): CommentNode[] { if (events.length === 0) return []; @@ -220,6 +333,9 @@ return neventEncode(commentEvent, $activeInboxRelays); } + // AI-NOTE: 2025-01-24 - View button functionality is working correctly + // This function navigates to the specific event as the main event, allowing + // users to view replies as the primary content function navigateToComment(commentEvent: NDKEvent) { const nevent = getNeventUrl(commentEvent); goto(`/events?id=${encodeURIComponent(nevent)}`); @@ -275,7 +391,9 @@ function getAuthorName(pubkey: string): string { const profile = profiles.get(pubkey); - return profile?.displayName || profile?.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; + if (profile?.displayName) return profile.displayName; + if (profile?.name) return profile.name; + return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; } function getAuthorPicture(pubkey: string): string | null { diff --git a/src/lib/components/EventDetails.svelte b/src/lib/components/EventDetails.svelte index 4bd78e4..c14d7e6 100644 --- a/src/lib/components/EventDetails.svelte +++ b/src/lib/components/EventDetails.svelte @@ -449,20 +449,22 @@ -
- {#if event.kind !== 0} - Content: -
- {@html showFullContent ? parsedContent : contentPreview} - {#if !showFullContent && parsedContent.length > 250} - - {/if} + {#if event.kind !== 0} +
+
+ Content: +
+ {@html showFullContent ? parsedContent : contentPreview} + {#if !showFullContent && parsedContent.length > 250} + + {/if} +
- {/if} -
+
+ {/if} {#if event.kind === 0} diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte index b06ea4e..389b8bd 100644 --- a/src/routes/events/+page.svelte +++ b/src/routes/events/+page.svelte @@ -11,7 +11,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; import { userStore } from "$lib/stores/userStore"; import { userBadge } from "$lib/snippets/UserSnippets.svelte"; - import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils"; + import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; import EventInput from "$lib/components/EventInput.svelte"; import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte"; import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; @@ -75,6 +75,25 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; } else { profile = null; } + + // AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author + if (newEvent.pubkey) { + cacheProfileForPubkey(newEvent.pubkey); + } + } + + // AI-NOTE: 2025-01-24 - Function to ensure profile is cached for a pubkey + async function cacheProfileForPubkey(pubkey: string) { + try { + const npub = toNpub(pubkey); + if (npub) { + // Force fetch to ensure profile is cached + await getUserMetadata(npub, true); + console.log(`[Events Page] Cached profile for pubkey: ${pubkey}`); + } + } catch (error) { + console.warn(`[Events Page] Failed to cache profile for ${pubkey}:`, error); + } } // Use Svelte 5 idiomatic effect to update searchValue when $page.url.searchParams.get('id') changes @@ -185,11 +204,32 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; checkCommunityStatusForResults(tTagEvents); } + // AI-NOTE: 2025-01-24 - Cache profiles for all search results + cacheProfilesForEvents([...results, ...secondOrder, ...tTagEvents]); + // Don't clear the current event - let the user continue viewing it // event = null; // profile = null; } + // AI-NOTE: 2025-01-24 - Function to cache profiles for multiple events + async function cacheProfilesForEvents(events: NDKEvent[]) { + const uniquePubkeys = new Set(); + events.forEach(event => { + if (event.pubkey) { + uniquePubkeys.add(event.pubkey); + } + }); + + console.log(`[Events Page] Caching profiles for ${uniquePubkeys.size} unique pubkeys`); + + // Cache profiles in parallel + const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey)); + await Promise.allSettled(cachePromises); + + console.log(`[Events Page] Profile caching complete`); + } + function handleClear() { searchType = null; searchTerm = null; @@ -233,48 +273,47 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; originalEventIds: Set, originalAddresses: Set, ): string { - // Check if this event has e-tags referencing original events - const eTags = getMatchingTags(event, "e"); - for (const tag of eTags) { - if (originalEventIds.has(tag[1])) { - return "Reply/Reference (e-tag)"; - } - } + const eTags = event.getMatchingTags("e"); + const aTags = event.getMatchingTags("a"); - // Check if this event has a-tags or e-tags referencing original events - let tags = getMatchingTags(event, "a"); - if (tags.length === 0) { - tags = getMatchingTags(event, "e"); + if (eTags.length > 0) { + const referencedEventId = eTags[eTags.length - 1][1]; + if (originalEventIds.has(referencedEventId)) { + return "Reply"; + } } - for (const tag of tags) { - if (originalAddresses.has(tag[1])) { - return "Reply/Reference (a-tag)"; + if (aTags.length > 0) { + const referencedAddress = aTags[aTags.length - 1][1]; + if (originalAddresses.has(referencedAddress)) { + return "Quote"; } } - // Check if this event has content references - if (event.content) { - for (const id of originalEventIds) { - const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, "i"); - const notePattern = new RegExp(`note1[a-z0-9]{50,}`, "i"); - if ( - neventPattern.test(event.content) || - notePattern.test(event.content) - ) { - return "Content Reference"; - } - } + return "Reference"; + } - for (const address of originalAddresses) { - const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, "i"); - if (naddrPattern.test(event.content)) { - return "Content Reference"; - } - } + // AI-NOTE: 2025-01-24 - Function to parse profile content from kind 0 events + function parseProfileContent(event: NDKEvent): { + name?: string; + display_name?: string; + about?: string; + picture?: string; + banner?: string; + website?: string; + lud16?: string; + nip05?: string; + } | null { + if (event.kind !== 0 || !event.content) { + return null; } - return "Reference"; + try { + return JSON.parse(event.content); + } catch (error) { + console.warn("Failed to parse profile content:", error); + return null; + } } function getNeventUrl(event: NDKEvent): string { @@ -427,6 +466,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
{#each searchResults as result, index} + {@const profileData = parseProfileContent(result)}
- {#if getSummary(result)} -
- {getSummary(result)} + {#if result.kind === 0 && profileData} +
+ {#if profileData.picture} + Profile { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} +
+ + {(profileData.display_name || profileData.name || result.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} +
+ {#if profileData.display_name || profileData.name} + + {profileData.display_name || profileData.name} + + {/if} + {#if profileData.about} + + {profileData.about} + + {/if} +
- {/if} - {#if getDeferralNaddr(result)} -
- Read - { - e.stopPropagation(); - navigateToPublication( - getDeferralNaddr(result) || "", - ); - }} - onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + {:else} + {#if getSummary(result)} +
+ {getSummary(result)} +
+ {/if} + {#if getDeferralNaddr(result)} +
+ Read + { e.stopPropagation(); navigateToPublication( getDeferralNaddr(result) || "", ); - } - }} - tabindex="0" - role="button" + }} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + navigateToPublication( + getDeferralNaddr(result) || "", + ); + } + }} + tabindex="0" + role="button" + > + {getDeferralNaddr(result)} + +
+ {/if} + {#if isAddressableEvent(result)} +
- {getDeferralNaddr(result)} - -
- {/if} - {#if isAddressableEvent(result)} -
- -
- {/if} - {#if result.content} -
- {result.content.slice(0, 200)}{result.content.length > - 200 - ? "..." - : ""} -
+ +
+ {/if} + {#if result.content} +
+ {result.content.slice(0, 200)}{result.content.length > + 200 + ? "..." + : ""} +
+ {/if} {/if}
@@ -551,6 +624,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";

{#each secondOrderResults as result, index} + {@const profileData = parseProfileContent(result)} @@ -675,6 +782,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";

{#each tTagResults as result, index} + {@const profileData = parseProfileContent(result)}
- {#if getSummary(result)} -
- {getSummary(result)} + {#if result.kind === 0 && profileData} +
+ {#if profileData.picture} + Profile { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} +
+ + {(profileData.display_name || profileData.name || result.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} +
+ {#if profileData.display_name || profileData.name} + + {profileData.display_name || profileData.name} + + {/if} + {#if profileData.about} + + {profileData.about} + + {/if} +
- {/if} - {#if getDeferralNaddr(result)} -
- Read - { - e.stopPropagation(); - navigateToPublication( - getDeferralNaddr(result) || "", - ); - }} - onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + {:else} + {#if getSummary(result)} +
+ {getSummary(result)} +
+ {/if} + {#if getDeferralNaddr(result)} +
+ Read + { e.stopPropagation(); navigateToPublication( getDeferralNaddr(result) || "", ); - } - }} - tabindex="0" - role="button" + }} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + navigateToPublication( + getDeferralNaddr(result) || "", + ); + } + }} + tabindex="0" + role="button" + > + {getDeferralNaddr(result)} + +
+ {/if} + {#if isAddressableEvent(result)} +
- {getDeferralNaddr(result)} - -
- {/if} - {#if isAddressableEvent(result)} -
- -
- {/if} - {#if result.content} -
- {result.content.slice(0, 200)}{result.content.length > - 200 - ? "..." - : ""} -
+ +
+ {/if} + {#if result.content} +
+ {result.content.slice(0, 200)}{result.content.length > + 200 + ? "..." + : ""} +
+ {/if} {/if}
From 11c605b5eff23870e41aea99d4e28e0e757bd5c2 Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 09:21:57 +0200 Subject: [PATCH 10/29] improved search some more --- src/lib/components/EventSearch.svelte | 132 ++++++++++++++++++++++++++ src/lib/utils/search_constants.ts | 7 +- src/lib/utils/subscription_search.ts | 50 +++++----- src/routes/events/+page.svelte | 12 ++- 4 files changed, 171 insertions(+), 30 deletions(-) diff --git a/src/lib/components/EventSearch.svelte b/src/lib/components/EventSearch.svelte index 10f888b..67dece6 100644 --- a/src/lib/components/EventSearch.svelte +++ b/src/lib/components/EventSearch.svelte @@ -488,6 +488,61 @@ searchType, searchTerm, }); + + // AI-NOTE: 2025-01-24 - Check cache first for profile searches to provide immediate response + if (searchType === "n") { + try { + const { getUserMetadata } = await import("$lib/utils/nostrUtils"); + const cachedProfile = await getUserMetadata(searchTerm, false); + if (cachedProfile && cachedProfile.name) { + console.log("EventSearch: Found cached profile, displaying immediately:", cachedProfile); + + // Create a mock NDKEvent for the cached profile + const { NDKEvent } = await import("@nostr-dev-kit/ndk"); + const { nip19 } = await import("$lib/utils/nostrUtils"); + + // Decode the npub to get the actual pubkey + let pubkey = searchTerm; + try { + const decoded = nip19.decode(searchTerm); + if (decoded && decoded.type === "npub") { + pubkey = decoded.data; + } + } catch (error) { + console.warn("EventSearch: Failed to decode npub for mock event:", error); + } + + const mockEvent = new NDKEvent(undefined, { + kind: 0, + pubkey: pubkey, + content: JSON.stringify(cachedProfile), + tags: [], + created_at: Math.floor(Date.now() / 1000), + id: "", // Will be computed by NDK + sig: "", // Will be computed by NDK + }); + + // Display the cached profile immediately + handleFoundEvent(mockEvent); + updateSearchState(false, true, 1, "profile-cached"); + + // AI-NOTE: 2025-01-24 - Still perform background search for second-order events + // but with better timeout handling to prevent hanging + setTimeout(async () => { + try { + await performBackgroundProfileSearch(searchType, searchTerm); + } catch (error) { + console.warn("EventSearch: Background profile search failed:", error); + } + }, 100); + + return; + } + } catch (error) { + console.warn("EventSearch: Cache check failed, proceeding with subscription search:", error); + } + } + isResetting = false; // Allow effects to run for new searches localError = null; updateSearchState(true); @@ -663,6 +718,83 @@ } } + // AI-NOTE: 2025-01-24 - Function to perform background profile search without blocking UI + async function performBackgroundProfileSearch( + searchType: "d" | "t" | "n", + searchTerm: string, + ) { + console.log("EventSearch: Performing background profile search:", { + searchType, + searchTerm, + }); + + try { + // Cancel existing search + if (currentAbortController) { + currentAbortController.abort(); + } + currentAbortController = new AbortController(); + + // AI-NOTE: 2025-01-24 - Add timeout to prevent hanging background searches + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Background search timeout")); + }, 10000); // 10 second timeout for background searches + }); + + const searchPromise = searchBySubscription( + searchType, + searchTerm, + { + onSecondOrderUpdate: (updatedResult) => { + console.log("EventSearch: Background second order update:", updatedResult); + // Only update if we have new results + if (updatedResult.events.length > 0) { + onSearchResults( + updatedResult.events, + updatedResult.secondOrder, + updatedResult.tTagEvents, + updatedResult.eventIds, + updatedResult.addresses, + updatedResult.searchType, + updatedResult.searchTerm, + ); + } + }, + onSubscriptionCreated: (sub) => { + console.log("EventSearch: Background subscription created:", sub); + if (activeSub) { + activeSub.stop(); + } + activeSub = sub; + }, + }, + currentAbortController.signal, + ); + + // Race between search and timeout + const result = await Promise.race([searchPromise, timeoutPromise]) as any; + + console.log("EventSearch: Background search completed:", result); + + // Only update results if we have new data + if (result.events.length > 0) { + onSearchResults( + result.events, + result.secondOrder, + result.tTagEvents, + result.eventIds, + result.addresses, + result.searchType, + result.searchTerm, + ); + } + } catch (error) { + console.warn("EventSearch: Background profile search failed:", error); + } + } + + // Search utility functions function handleClear() { isResetting = true; searchQuery = ""; diff --git a/src/lib/utils/search_constants.ts b/src/lib/utils/search_constants.ts index 663e985..cc6f677 100644 --- a/src/lib/utils/search_constants.ts +++ b/src/lib/utils/search_constants.ts @@ -17,7 +17,7 @@ export const TIMEOUTS = { SUBSCRIPTION_SEARCH: 10000, /** Timeout for second-order search operations */ - SECOND_ORDER_SEARCH: 5000, + SECOND_ORDER_SEARCH: 3000, // AI-NOTE: 2025-01-24 - Reduced timeout since we limit scope /** Timeout for relay diagnostics */ RELAY_DIAGNOSTICS: 5000, @@ -44,7 +44,10 @@ export const SEARCH_LIMITS = { SPECIFIC_PROFILE: 10, /** Limit for general profile searches */ - GENERAL_PROFILE: 500, + GENERAL_PROFILE: 100, // AI-NOTE: 2025-01-24 - Reduced from 500 to prevent wild searches + + /** Limit for general content searches (t-tag, d-tag, etc.) */ + GENERAL_CONTENT: 100, // AI-NOTE: 2025-01-24 - Added limit for all content searches /** Limit for community relay checks */ COMMUNITY_CHECK: 1, diff --git a/src/lib/utils/subscription_search.ts b/src/lib/utils/subscription_search.ts index d992f5b..d07067e 100644 --- a/src/lib/utils/subscription_search.ts +++ b/src/lib/utils/subscription_search.ts @@ -59,19 +59,11 @@ export async function searchBySubscription( const cachedResult = searchCache.get(searchType, normalizedSearchTerm); if (cachedResult) { console.log("subscription_search: Found cached result:", cachedResult); - // AI-NOTE: 2025-01-08 - For profile searches, clear cache if it's empty to force fresh search - if (searchType === "n" && cachedResult.events.length === 0) { - console.log("subscription_search: Clearing empty cached profile result to force fresh search"); - searchCache.clear(); // Clear all cache to force fresh search - } else if (searchType === "n" && cachedResult.events.length > 0 && cachedResult.secondOrder.length === 0) { - // AI-NOTE: 2025-01-08 - Clear cache if we have profile results but no second-order events - // This forces a fresh search that includes second-order searching - console.log("subscription_search: Clearing cached profile result with no second-order events to force fresh search"); - searchCache.clear(); - } else if (searchType === "n") { - // AI-NOTE: 2025-01-08 - For profile searches, always clear cache to ensure fresh second-order search - console.log("subscription_search: Clearing cache for profile search to ensure fresh second-order search"); - searchCache.clear(); + // AI-NOTE: 2025-01-24 - For profile searches, return cached results immediately + // The EventSearch component now handles cache checking before calling this function + if (searchType === "n") { + console.log("subscription_search: Returning cached profile result immediately"); + return cachedResult; } else { return cachedResult; } @@ -91,7 +83,7 @@ export async function searchBySubscription( searchState.timeoutId = setTimeout(() => { console.log("subscription_search: Search timeout reached"); cleanup(); - }, searchType === "n" ? 5000 : TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: 2025-01-08 - Shorter timeout for profile searches + }, TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: 2025-01-24 - Use standard timeout since cache is checked first // Check for abort signal if (abortSignal?.aborted) { @@ -332,7 +324,7 @@ async function createSearchFilter( switch (searchType) { case "d": { const dFilter = { - filter: { "#d": [normalizedSearchTerm] }, + filter: { "#d": [normalizedSearchTerm], limit: SEARCH_LIMITS.GENERAL_CONTENT }, subscriptionType: "d-tag", }; console.log("subscription_search: Created d-tag filter:", dFilter); @@ -340,7 +332,7 @@ async function createSearchFilter( } case "t": { const tFilter = { - filter: { "#t": [normalizedSearchTerm] }, + filter: { "#t": [normalizedSearchTerm], limit: SEARCH_LIMITS.GENERAL_CONTENT }, subscriptionType: "t-tag", }; console.log("subscription_search: Created t-tag filter:", tFilter); @@ -929,21 +921,33 @@ async function performSecondOrderSearchInBackground( if (searchType === "n" && targetPubkey) { console.log("subscription_search: Searching for events mentioning pubkey:", targetPubkey); + // AI-NOTE: 2025-01-24 - Use only active relays for second-order profile search to prevent hanging + const activeRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; + const availableRelays = activeRelays + .map(url => ndk.pool.relays.get(url)) + .filter((relay): relay is any => relay !== undefined); + const relaySet = new NDKRelaySet( + new Set(availableRelays), + ndk + ); + + console.log("subscription_search: Using", activeRelays.length, "active relays for second-order search"); + // Search for events that mention this pubkey via p-tags - const pTagFilter = { "#p": [targetPubkey] }; + const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging const pTagEvents = await ndk.fetchEvents( pTagFilter, { closeOnEose: true }, - new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), + relaySet, ); console.log("subscription_search: Found", pTagEvents.size, "events with p-tag for", targetPubkey); - // AI-NOTE: 2025-01-08 - Also search for events written by this pubkey - const authorFilter = { authors: [targetPubkey] }; + // AI-NOTE: 2025-01-24 - Also search for events written by this pubkey with limit + const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging const authorEvents = await ndk.fetchEvents( authorFilter, { closeOnEose: true }, - new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), + relaySet, ); console.log("subscription_search: Found", authorEvents.size, "events written by", targetPubkey); @@ -964,14 +968,14 @@ async function performSecondOrderSearchInBackground( const [eTagEvents, aTagEvents] = await Promise.all([ eventIds.size > 0 ? ndk.fetchEvents( - { "#e": Array.from(eventIds) }, + { "#e": Array.from(eventIds), limit: SEARCH_LIMITS.SECOND_ORDER_RESULTS }, { closeOnEose: true }, relaySet, ) : Promise.resolve([]), addresses.size > 0 ? ndk.fetchEvents( - { "#a": Array.from(addresses) }, + { "#a": Array.from(addresses), limit: SEARCH_LIMITS.SECOND_ORDER_RESULTS }, { closeOnEose: true }, relaySet, ) diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte index 389b8bd..f37d5a8 100644 --- a/src/routes/events/+page.svelte +++ b/src/routes/events/+page.svelte @@ -453,15 +453,17 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; {#if searchResults.length > 0}
- + {#if searchType === "n"} - Search Results for name: "{searchTerm}" ({searchResults.length} profiles) + Search Results for name: "{searchTerm && searchTerm.length > 50 ? searchTerm.slice(0, 50) + '...' : searchTerm || ''}" ({searchResults.length} profiles) {:else if searchType === "t"} - Search Results for t-tag: "{searchTerm}" ({searchResults.length} + Search Results for t-tag: "{searchTerm && searchTerm.length > 50 ? searchTerm.slice(0, 50) + '...' : searchTerm || ''}" ({searchResults.length} events) {:else} - Search Results for d-tag: "{searchTerm || - dTagValue?.toLowerCase()}" ({searchResults.length} events) + Search Results for d-tag: "{(() => { + const term = searchTerm || dTagValue?.toLowerCase() || ''; + return term.length > 50 ? term.slice(0, 50) + '...' : term; + })()}" ({searchResults.length} events) {/if}
From 715efad96e6323d41a274810dfd839c9cd766202 Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 17:12:27 +0200 Subject: [PATCH 11/29] fixed kind 1111 comments and added highlights --- src/lib/components/CommentViewer.svelte | 487 +++++++++++++++++++++--- src/lib/consts.ts | 3 + src/lib/utils.ts | 27 +- src/lib/utils/event_search.ts | 64 +++- 4 files changed, 523 insertions(+), 58 deletions(-) diff --git a/src/lib/components/CommentViewer.svelte b/src/lib/components/CommentViewer.svelte index fb819e1..3b44665 100644 --- a/src/lib/components/CommentViewer.svelte +++ b/src/lib/components/CommentViewer.svelte @@ -14,6 +14,8 @@ // AI-NOTE: 2025-01-08 - Clean, efficient comment viewer implementation // This component fetches and displays threaded comments with proper hierarchy // Uses simple, reliable profile fetching and efficient state management + // AI-NOTE: 2025-01-24 - Added support for kind 9802 highlights (NIP-84) + // Highlights are displayed with special styling and include source attribution // State management let comments: NDKEvent[] = $state([]); @@ -84,32 +86,78 @@ } try { - // Try multiple filter approaches to find comments + // Build address for NIP-22 search if this is a replaceable event + let eventAddress: string | null = null; + if (event.kind && event.pubkey) { + const dTag = event.getMatchingTags("d")[0]?.[1]; + if (dTag) { + eventAddress = `${event.kind}:${event.pubkey}:${dTag}`; + } + } + + console.log(`[CommentViewer] Event address for NIP-22: ${eventAddress}`); + + // Use more targeted filters to reduce noise const filters = [ - // Standard comment filter + // Primary filter: events that explicitly reference our target via e-tags { - kinds: [1, 1111], + kinds: [1, 1111, 9802], "#e": [event.id], - }, - // Broader search for any events that might reference this event - { - kinds: [1, 1111], - "#e": [event.id], - limit: 100, - }, - // Search for events by the same author that might be replies - { - kinds: [1, 1111], - authors: [event.pubkey], - since: event.created_at ? event.created_at - 86400 : undefined, // Last 24 hours limit: 50, } ]; - console.log(`[CommentViewer] Setting up subscription with filters:`, filters); + // Add NIP-22 filter only if we have a valid event address + if (eventAddress) { + filters.push({ + kinds: [1111, 9802], + "#a": [eventAddress], + limit: 50, + } as any); + } + + console.log(`[CommentViewer] Setting up subscription with ${filters.length} filters:`, filters); + + // Debug: Check if the provided event would match our filters + console.log(`[CommentViewer] Debug: Checking if event b9a15298f2b203d42ba6d0c56c43def87efc887697460c0febb9542515d5a00b would match our filters`); + console.log(`[CommentViewer] Debug: Target event ID: ${event.id}`); + console.log(`[CommentViewer] Debug: Event address: ${eventAddress}`); + + // Get all available relays for a more comprehensive search + // Use the full NDK pool relays instead of just active relays + const ndkPoolRelays = Array.from($ndkInstance.pool.relays.values()).map(relay => relay.url); + console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays); + + // Try all filters to find comments with full relay set + activeSub = $ndkInstance.subscribe(filters); - // Try the first filter (standard comment search) - activeSub = $ndkInstance.subscribe(filters[0]); + // Also try a direct search for the specific comment we're looking for + console.log(`[CommentViewer] Also searching for specific comment: 64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942`); + const specificCommentSub = $ndkInstance.subscribe({ + ids: ["64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942"] + }); + + specificCommentSub.on("event", (specificEvent: NDKEvent) => { + console.log(`[CommentViewer] Found specific comment via direct search:`, specificEvent.id); + console.log(`[CommentViewer] Specific comment tags:`, specificEvent.tags); + + // Check if this specific comment references our target + const eTags = specificEvent.getMatchingTags("e"); + const aTags = specificEvent.getMatchingTags("a"); + console.log(`[CommentViewer] Specific comment e-tags:`, eTags.map(t => t[1])); + console.log(`[CommentViewer] Specific comment a-tags:`, aTags.map(t => t[1])); + + const hasETag = eTags.some(tag => tag[1] === event.id); + const hasATag = eventAddress ? aTags.some(tag => tag[1] === eventAddress) : false; + + console.log(`[CommentViewer] Specific comment has matching e-tag: ${hasETag}`); + console.log(`[CommentViewer] Specific comment has matching a-tag: ${hasATag}`); + }); + + specificCommentSub.on("eose", () => { + console.log(`[CommentViewer] Specific comment search EOSE`); + specificCommentSub.stop(); + }); const timeout = setTimeout(() => { console.log(`[CommentViewer] Subscription timeout - no comments found`); @@ -126,16 +174,54 @@ console.log(`[CommentViewer] Comment pubkey: ${commentEvent.pubkey}`); console.log(`[CommentViewer] Comment content preview: ${commentEvent.content?.slice(0, 100)}...`); + // Special debug for the specific comment we're looking for + if (commentEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") { + console.log(`[CommentViewer] DEBUG: Found the specific comment we're looking for!`); + console.log(`[CommentViewer] DEBUG: Comment tags:`, commentEvent.tags); + } + // Check if this event actually references our target event + let referencesTarget = false; + let referenceMethod = ""; + + // Check e-tags (standard format) const eTags = commentEvent.getMatchingTags("e"); - const referencesTarget = eTags.some(tag => tag[1] === event.id); + console.log(`[CommentViewer] Checking e-tags:`, eTags.map(t => t[1])); + console.log(`[CommentViewer] Target event ID: ${event.id}`); + const hasETag = eTags.some(tag => tag[1] === event.id); + console.log(`[CommentViewer] Has matching e-tag: ${hasETag}`); + if (hasETag) { + referencesTarget = true; + referenceMethod = "e-tag"; + } + + // Check a-tags (NIP-22 format) if not found via e-tags + if (!referencesTarget && eventAddress) { + const aTags = commentEvent.getMatchingTags("a"); + console.log(`[CommentViewer] Checking a-tags:`, aTags.map(t => t[1])); + console.log(`[CommentViewer] Expected a-tag: ${eventAddress}`); + const hasATag = aTags.some(tag => tag[1] === eventAddress); + console.log(`[CommentViewer] Has matching a-tag: ${hasATag}`); + if (hasATag) { + referencesTarget = true; + referenceMethod = "a-tag"; + } + } if (referencesTarget) { - console.log(`[CommentViewer] Comment references target event - adding to comments`); + console.log(`[CommentViewer] Comment references target event via ${referenceMethod} - adding to comments`); comments = [...comments, commentEvent]; fetchProfile(commentEvent.pubkey); + + // Fetch nested replies for this comment + fetchNestedReplies(commentEvent.id); } else { console.log(`[CommentViewer] Comment does not reference target event - skipping`); + console.log(`[CommentViewer] e-tags:`, eTags.map(t => t[1])); + if (eventAddress) { + console.log(`[CommentViewer] a-tags:`, commentEvent.getMatchingTags("a").map(t => t[1])); + console.log(`[CommentViewer] expected a-tag:`, eventAddress); + } } }); @@ -151,6 +237,11 @@ // Pre-fetch all profiles after comments are loaded preFetchAllProfiles(); + // AI-NOTE: 2025-01-24 - Fetch nested replies for all found comments + comments.forEach(comment => { + fetchNestedReplies(comment.id); + }); + // AI-NOTE: 2025-01-24 - Test for comments if none were found if (comments.length === 0) { testForComments(); @@ -193,25 +284,35 @@ console.log(`[CommentViewer] Pre-fetching complete`); } - // AI-NOTE: 2025-01-24 - Function to manually test for comments - async function testForComments() { - if (!event?.id) return; - - console.log(`[CommentViewer] Testing for comments on event: ${event.id}`); - - try { - // Try a broader search to see if there are any events that might be comments - const testSub = $ndkInstance.subscribe({ - kinds: [1, 1111], - "#e": [event.id], - limit: 10, - }); + // AI-NOTE: 2025-01-24 - Function to manually test for comments + async function testForComments() { + if (!event?.id) return; + + console.log(`[CommentViewer] Testing for comments on event: ${event.id}`); + + try { + // Try a broader search to see if there are any events that might be comments + const testSub = $ndkInstance.subscribe({ + kinds: [1, 1111, 9802], + "#e": [event.id], + limit: 10, + }); let testComments = 0; testSub.on("event", (testEvent: NDKEvent) => { testComments++; - console.log(`[CommentViewer] Test found event: ${testEvent.id}, kind: ${testEvent.kind}`); + console.log(`[CommentViewer] Test found event: ${testEvent.id}, kind: ${testEvent.kind}, content: ${testEvent.content?.slice(0, 50)}...`); + + // Special debug for the specific comment we're looking for + if (testEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") { + console.log(`[CommentViewer] DEBUG: Test found the specific comment we're looking for!`); + console.log(`[CommentViewer] DEBUG: Test comment tags:`, testEvent.tags); + } + + // Show the e-tags to help debug + const eTags = testEvent.getMatchingTags("e"); + console.log(`[CommentViewer] Test event e-tags:`, eTags.map(t => t[1])); }); testSub.on("eose", () => { @@ -266,12 +367,36 @@ } } } else if (event.kind === 1111) { - // Kind 1111: Look for lowercase e-tags (immediate parent) - for (const tag of eTags) { - const referencedId = tag[1]; - if (eventMap.has(referencedId) && referencedId !== event.id) { - parentId = referencedId; - break; + // Kind 1111: Use NIP-22 threading format + // First try to find parent using 'a' tags (NIP-22 parent scope) + const aTags = event.getMatchingTags("a"); + for (const tag of aTags) { + const address = tag[1]; + // Extract event ID from address if it's a coordinate + const parts = address.split(":"); + if (parts.length >= 3) { + const [kind, pubkey, dTag] = parts; + // Look for the parent event with this address + for (const [eventId, parentEvent] of eventMap) { + if (parentEvent.kind === parseInt(kind) && + parentEvent.pubkey === pubkey && + parentEvent.getMatchingTags("d")[0]?.[1] === dTag) { + parentId = eventId; + break; + } + } + if (parentId) break; + } + } + + // Fallback to 'e' tags if no parent found via 'a' tags + if (!parentId) { + for (const tag of eTags) { + const referencedId = tag[1]; + if (eventMap.has(referencedId) && referencedId !== event.id) { + parentId = referencedId; + break; + } } } } @@ -310,6 +435,7 @@ // Fetch comments when event changes $effect(() => { if (event?.id) { + console.log(`[CommentViewer] Event changed, fetching comments for:`, event.id, `kind:`, event.kind); if (activeSub) { activeSub.stop(); activeSub = null; @@ -318,6 +444,110 @@ } }); + // AI-NOTE: 2025-01-24 - Add recursive comment fetching for nested replies + let isFetchingNestedReplies = $state(false); + let nestedReplyIds = $state>(new Set()); + + // Function to fetch nested replies for a given event + async function fetchNestedReplies(eventId: string) { + if (isFetchingNestedReplies || nestedReplyIds.has(eventId)) { + console.log(`[CommentViewer] Skipping nested reply fetch for ${eventId} - already fetching or processed`); + return; + } + + console.log(`[CommentViewer] Starting nested reply fetch for event: ${eventId}`); + isFetchingNestedReplies = true; + nestedReplyIds.add(eventId); + + try { + console.log(`[CommentViewer] Fetching nested replies for event:`, eventId); + + // Search for replies to this specific event + const nestedSub = $ndkInstance.subscribe({ + kinds: [1, 1111, 9802], + "#e": [eventId], + limit: 50, + }); + + let nestedCount = 0; + + nestedSub.on("event", (nestedEvent: NDKEvent) => { + console.log(`[CommentViewer] Found nested reply:`, nestedEvent.id, `kind:`, nestedEvent.kind); + + // Check if this event actually references the target event + const eTags = nestedEvent.getMatchingTags("e"); + const referencesTarget = eTags.some(tag => tag[1] === eventId); + + console.log(`[CommentViewer] Nested reply references target:`, referencesTarget, `eTags:`, eTags); + + if (referencesTarget && !comments.some(c => c.id === nestedEvent.id)) { + console.log(`[CommentViewer] Adding nested reply to comments`); + comments = [...comments, nestedEvent]; + fetchProfile(nestedEvent.pubkey); + + // Recursively fetch replies to this nested reply + fetchNestedReplies(nestedEvent.id); + } else if (!referencesTarget) { + console.log(`[CommentViewer] Nested reply does not reference target, skipping`); + } else { + console.log(`[CommentViewer] Nested reply already exists in comments`); + } + }); + + nestedSub.on("eose", () => { + console.log(`[CommentViewer] Nested replies EOSE, found ${nestedCount} replies`); + nestedSub.stop(); + isFetchingNestedReplies = false; + }); + + // Also search for NIP-22 format nested replies + const event = comments.find(c => c.id === eventId); + if (event && event.kind && event.pubkey) { + const dTag = event.getMatchingTags("d")[0]?.[1]; + if (dTag) { + const eventAddress = `${event.kind}:${event.pubkey}:${dTag}`; + + const nip22Sub = $ndkInstance.subscribe({ + kinds: [1111, 9802], + "#a": [eventAddress], + limit: 50, + }); + + nip22Sub.on("event", (nip22Event: NDKEvent) => { + console.log(`[CommentViewer] Found NIP-22 nested reply:`, nip22Event.id, `kind:`, nip22Event.kind); + + const aTags = nip22Event.getMatchingTags("a"); + const referencesTarget = aTags.some(tag => tag[1] === eventAddress); + + console.log(`[CommentViewer] NIP-22 nested reply references target:`, referencesTarget, `aTags:`, aTags, `eventAddress:`, eventAddress); + + if (referencesTarget && !comments.some(c => c.id === nip22Event.id)) { + console.log(`[CommentViewer] Adding NIP-22 nested reply to comments`); + comments = [...comments, nip22Event]; + fetchProfile(nip22Event.pubkey); + + // Recursively fetch replies to this nested reply + fetchNestedReplies(nip22Event.id); + } else if (!referencesTarget) { + console.log(`[CommentViewer] NIP-22 nested reply does not reference target, skipping`); + } else { + console.log(`[CommentViewer] NIP-22 nested reply already exists in comments`); + } + }); + + nip22Sub.on("eose", () => { + console.log(`[CommentViewer] NIP-22 nested replies EOSE`); + nip22Sub.stop(); + }); + } + } + + } catch (err) { + console.error(`[CommentViewer] Error fetching nested replies:`, err); + isFetchingNestedReplies = false; + } + } + // Cleanup on unmount onMount(() => { return () => { @@ -330,15 +560,31 @@ // Navigation functions function getNeventUrl(commentEvent: NDKEvent): string { - return neventEncode(commentEvent, $activeInboxRelays); + try { + console.log(`[CommentViewer] Generating nevent for:`, commentEvent.id, `kind:`, commentEvent.kind); + const nevent = neventEncode(commentEvent, $activeInboxRelays); + console.log(`[CommentViewer] Generated nevent:`, nevent); + return nevent; + } catch (error) { + console.error(`[CommentViewer] Error generating nevent:`, error); + // Fallback to just the event ID + return commentEvent.id; + } } // AI-NOTE: 2025-01-24 - View button functionality is working correctly // This function navigates to the specific event as the main event, allowing // users to view replies as the primary content function navigateToComment(commentEvent: NDKEvent) { - const nevent = getNeventUrl(commentEvent); - goto(`/events?id=${encodeURIComponent(nevent)}`); + try { + const nevent = getNeventUrl(commentEvent); + console.log(`[CommentViewer] Navigating to comment:`, nevent); + goto(`/events?id=${encodeURIComponent(nevent)}`); + } catch (error) { + console.error(`[CommentViewer] Error navigating to comment:`, error); + // Fallback to event ID + goto(`/events?id=${commentEvent.id}`); + } } // Utility functions @@ -414,6 +660,39 @@ return parsedContent; } + + + + // AI-NOTE: 2025-01-24 - Get highlight source information + function getHighlightSource(highlightEvent: NDKEvent): { type: string; value: string; url?: string } | null { + // Check for e-tags (nostr events) + const eTags = highlightEvent.getMatchingTags("e"); + if (eTags.length > 0) { + return { type: "nostr_event", value: eTags[0][1] }; + } + + // Check for r-tags (URLs) + const rTags = highlightEvent.getMatchingTags("r"); + if (rTags.length > 0) { + return { type: "url", value: rTags[0][1], url: rTags[0][1] }; + } + + return null; + } + + // AI-NOTE: 2025-01-24 - Get highlight attribution + function getHighlightAttribution(highlightEvent: NDKEvent): Array<{ pubkey: string; role?: string }> { + const pTags = highlightEvent.getMatchingTags("p"); + return pTags.map(tag => ({ + pubkey: tag[1], + role: tag[3] || undefined + })); + } + + // AI-NOTE: 2025-01-24 - Check if highlight has comment + function hasHighlightComment(highlightEvent: NDKEvent): boolean { + return highlightEvent.getMatchingTags("comment").length > 0; + } @@ -474,11 +753,84 @@
- {#await parseContent(node.event.content || "") then parsedContent} - {@html parsedContent} - {:catch} - {@html node.event.content || ""} - {/await} + {#if node.event.kind === 9802} + +
+ {#if hasHighlightComment(node.event)} + +
+
+ Highlighted content: +
+ {#if node.event.getMatchingTags("context")[0]?.[1]} +
+ {@html node.event.getMatchingTags("context")[0]?.[1]} +
+ {:else} +
+ {node.event.content || ""} +
+ {/if} + {#if getHighlightSource(node.event)} +
+ Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'} +
+ {/if} +
+
+
+ Comment: +
+ {#await parseContent(node.event.getMatchingTags("comment")[0]?.[1] || "") then parsedContent} + {@html parsedContent} + {:catch} + {@html node.event.getMatchingTags("comment")[0]?.[1] || ""} + {/await} +
+ {:else} + + {#if node.event.getMatchingTags("context")[0]?.[1]} +
+ {@html node.event.getMatchingTags("context")[0]?.[1]} +
+ {:else} +
+ {node.event.content || ""} +
+ {/if} + + {#if getHighlightSource(node.event)} +
+ Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'} +
+ {/if} + {/if} + + {#if getHighlightAttribution(node.event).length > 0} +
+ Attribution: + {#each getHighlightAttribution(node.event) as attribution} + + {/each} +
+ {/if} +
+ {:else} + + {#await parseContent(node.event.content || "") then parsedContent} + {@html parsedContent} + {:catch} + {@html node.event.content || ""} + {/await} + {/if}
@@ -494,7 +846,7 @@
- Comments ({threadedComments.length}) + Comments & Highlights ({threadedComments.length}) {#if loading} @@ -507,7 +859,7 @@
{:else if threadedComments.length === 0}
-

No comments yet. Be the first to comment!

+

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

{:else}
@@ -516,4 +868,37 @@ {/each}
{/if} -
\ No newline at end of file +
+ + \ No newline at end of file diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 90afa53..29f4502 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -14,6 +14,9 @@ export const searchRelays = [ "wss://aggr.nostr.land", "wss://relay.noswhere.com", "wss://nostr.wine", + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://freelay.sovbit.host" ]; export const secondaryRelays = [ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ee44929..18fad03 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -19,12 +19,27 @@ export class InvalidKindError extends DecodeError { } export function neventEncode(event: NDKEvent, relays: string[]) { - return nip19.neventEncode({ - id: event.id, - kind: event.kind, - relays, - author: event.pubkey, - }); + try { + console.log(`[neventEncode] Encoding event:`, { + id: event.id, + kind: event.kind, + pubkey: event.pubkey, + relayCount: relays.length + }); + + const nevent = nip19.neventEncode({ + id: event.id, + kind: event.kind, + relays, + author: event.pubkey, + }); + + console.log(`[neventEncode] Generated nevent:`, nevent); + return nevent; + } catch (error) { + console.error(`[neventEncode] Error encoding nevent:`, error); + throw error; + } } export function naddrEncode(event: NDKEvent, relays: string[]) { diff --git a/src/lib/utils/event_search.ts b/src/lib/utils/event_search.ts index 1d5537d..f15b9b3 100644 --- a/src/lib/utils/event_search.ts +++ b/src/lib/utils/event_search.ts @@ -1,5 +1,5 @@ import { ndkInstance } from "../ndk.ts"; -import { fetchEventWithFallback } from "./nostrUtils.ts"; +import { fetchEventWithFallback, NDKRelaySetFromNDK } from "./nostrUtils.ts"; import { nip19 } from "nostr-tools"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import type { Filter } from "./search_types.ts"; @@ -11,6 +11,26 @@ import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; * Search for a single event by ID or filter */ export async function searchEvent(query: string): Promise { + const ndk = get(ndkInstance); + if (!ndk) { + console.warn("[Search] No NDK instance available"); + return null; + } + + // Wait for relays to be available + let attempts = 0; + const maxAttempts = 10; + while (ndk.pool.relays.size === 0 && attempts < maxAttempts) { + console.log(`[Search] Waiting for relays to be available (attempt ${attempts + 1}/${maxAttempts})`); + await new Promise(resolve => setTimeout(resolve, 500)); + attempts++; + } + + if (ndk.pool.relays.size === 0) { + console.warn("[Search] No relays available after waiting"); + return null; + } + // Clean the query and normalize to lowercase const cleanedQuery = query.replace(/^nostr:/, "").toLowerCase(); let filterOrId: Filter | string = cleanedQuery; @@ -51,8 +71,50 @@ export async function searchEvent(query: string): Promise { try { const decoded = nip19.decode(cleanedQuery); if (!decoded) throw new Error("Invalid identifier"); + + console.log(`[Search] Decoded identifier:`, { + type: decoded.type, + data: decoded.data, + query: cleanedQuery + }); + switch (decoded.type) { case "nevent": + console.log(`[Search] Processing nevent:`, { + id: decoded.data.id, + kind: decoded.data.kind, + relays: decoded.data.relays + }); + + // Use the relays from the nevent if available + if (decoded.data.relays && decoded.data.relays.length > 0) { + console.log(`[Search] Using relays from nevent:`, decoded.data.relays); + + // Try to fetch the event using the nevent's relays + try { + // Create a temporary relay set for this search + const neventRelaySet = NDKRelaySetFromNDK.fromRelayUrls(decoded.data.relays, ndk); + + if (neventRelaySet.relays.size > 0) { + console.log(`[Search] Created relay set with ${neventRelaySet.relays.size} relays from nevent`); + + // Try to fetch the event using the nevent's relays + const event = await ndk + .fetchEvent({ ids: [decoded.data.id] }, undefined, neventRelaySet) + .withTimeout(TIMEOUTS.EVENT_FETCH); + + if (event) { + console.log(`[Search] Found event using nevent relays:`, event.id); + return event; + } else { + console.log(`[Search] Event not found on nevent relays, trying default relays`); + } + } + } catch (error) { + console.warn(`[Search] Error fetching from nevent relays:`, error); + } + } + filterOrId = decoded.data.id; break; case "note": From 0b45c3e97c3ab70c095df0f1304f7dd2a665da37 Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 18:05:04 +0200 Subject: [PATCH 12/29] Personal notifications added to "View Profile" --- src/lib/components/EventDetails.svelte | 4 + src/lib/components/Notifications.svelte | 451 ++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 src/lib/components/Notifications.svelte diff --git a/src/lib/components/EventDetails.svelte b/src/lib/components/EventDetails.svelte index c14d7e6..3a8801e 100644 --- a/src/lib/components/EventDetails.svelte +++ b/src/lib/components/EventDetails.svelte @@ -14,6 +14,7 @@ import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; import { navigateToEvent } from "$lib/utils/nostrEventService"; import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte"; + import Notifications from "$lib/components/Notifications.svelte"; const { event, @@ -473,6 +474,9 @@ {profile} identifiers={getIdentifiers(event, profile)} /> + + + {/if} diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte new file mode 100644 index 0000000..9566bcb --- /dev/null +++ b/src/lib/components/Notifications.svelte @@ -0,0 +1,451 @@ + + +{#if isOwnProfile && $userStore.signedIn} +
+
+ Notifications + + +
+ + +
+
+ + {#if loading} +
+
+ Loading notifications... +
+ {:else if error} +
+

Error loading notifications: {error}

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

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

+
+ {:else} +
+
+ {#each notifications.slice(0, 10) as notification} + {@const authorProfile = authorProfiles.get(notification.pubkey)} +
+
+ +
+ {#if authorProfile?.picture} + Author avatar { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {:else} +
+ + {(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} +
+ + +
+
+ + {getNotificationType(notification)} + + + {notification.created_at ? formatDate(notification.created_at) : "Unknown date"} + +
+ + +
+ + {authorProfile?.displayName || authorProfile?.name || `${notification.pubkey.slice(0, 8)}...${notification.pubkey.slice(-4)}`} + + {#if authorProfile?.name && authorProfile?.displayName && authorProfile.name !== authorProfile.displayName} + + (@{authorProfile.name}) + + {/if} +
+ + {#if notification.content} +
+ {truncateContent(notification.content)} +
+ {/if} + +
+ + + {getNeventUrl(notification).slice(0, 16)}... + +
+
+
+
+ {/each} +
+ + {#if notifications.length > 10} +
+ Showing 10 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more. +
+ {/if} +
+ {/if} +
+{/if} \ No newline at end of file From 1da3f6ba0d95b50276ff868ccc887171816265d8 Mon Sep 17 00:00:00 2001 From: silberengel Date: Wed, 6 Aug 2025 23:50:09 +0200 Subject: [PATCH 13/29] Worked on the notifications some more --- src/lib/components/EventDetails.svelte | 2 +- src/lib/components/Notifications.svelte | 687 ++++++++++++++---------- 2 files changed, 397 insertions(+), 292 deletions(-) diff --git a/src/lib/components/EventDetails.svelte b/src/lib/components/EventDetails.svelte index 3a8801e..dcdd0eb 100644 --- a/src/lib/components/EventDetails.svelte +++ b/src/lib/components/EventDetails.svelte @@ -454,7 +454,7 @@
Content: -
+
{@html showFullContent ? parsedContent : contentPreview} {#if !showFullContent && parsedContent.length > 250} - + +
+ {#each ["to-me", "from-me", "public-messages"] as mode} + {@const modeLabel = mode === "to-me" ? "To Me" : mode === "from-me" ? "From Me" : "Public Messages"} + + {/each}
{#if loading}
- Loading notifications... + + Loading {notificationMode === "public-messages" ? "public messages" : "notifications"}... +
{:else if error}
-

Error loading notifications: {error}

-
- {:else if notifications.length === 0} -
-

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

+

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

- {:else} -
-
- {#each notifications.slice(0, 10) as notification} - {@const authorProfile = authorProfiles.get(notification.pubkey)} -
-
- -
- {#if authorProfile?.picture} - Author avatar { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - {:else} -
- - {(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()} + {:else if notificationMode === "public-messages"} + {#if publicMessages.length === 0} +
+

No public messages found.

+
+ {:else} +
+ {#if filteredByUser} +
+
+ + Filtered by user: {authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || `${filteredByUser.slice(0, 8)}...${filteredByUser.slice(-4)}`} + + +
+
+ {/if} +
+ {#each filteredMessages.slice(0, 20) as message} + {@const authorProfile = authorProfiles.get(message.pubkey)} + {@const isFromUser = message.pubkey === $userStore.pubkey} +
+
+ +
+ {#if authorProfile?.picture} + Author avatar (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else} +
+ + {(authorProfile?.displayName || authorProfile?.name || message.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} + + + {#if !isFromUser} +
+ +
+ {/if} +
+ + +
+
+ + {isFromUser ? 'Your Message' : 'Public Message'} + + + {message.created_at ? formatDate(message.created_at) : "Unknown date"} + +
+ + +
+ + {authorProfile?.displayName || authorProfile?.name || `${message.pubkey.slice(0, 8)}...${message.pubkey.slice(-4)}`} + {#if authorProfile?.name && authorProfile?.displayName && authorProfile.name !== authorProfile.displayName} + + (@{authorProfile.name}) + + {/if}
- {/if} + + {#if message.content} +
+ {truncateContent(message.content)} +
+ {/if} + +
+ + + {getNeventUrl(message).slice(0, 16)}... + +
+
- - -
-
- - {getNotificationType(notification)} - - - {notification.created_at ? formatDate(notification.created_at) : "Unknown date"} - +
+ {/each} +
+ + {#if filteredMessages.length > 20} +
+ Showing 20 of {filteredMessages.length} messages {filteredByUser ? `(filtered)` : ''}. Scroll to see more. +
+ {/if} +
+ {/if} + {:else} + {#if notifications.length === 0} +
+

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

+
+ {:else} +
+
+ {#each notifications.slice(0, 10) as notification} + {@const authorProfile = authorProfiles.get(notification.pubkey)} +
+
+ +
+ {#if authorProfile?.picture} + Author avatar (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else} +
+ + {(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if}
- -
- - {authorProfile?.displayName || authorProfile?.name || `${notification.pubkey.slice(0, 8)}...${notification.pubkey.slice(-4)}`} - - {#if authorProfile?.name && authorProfile?.displayName && authorProfile.name !== authorProfile.displayName} - - (@{authorProfile.name}) + +
+
+ + {getNotificationType(notification)} + + {notification.created_at ? formatDate(notification.created_at) : "Unknown date"} + +
+ + +
+ + {authorProfile?.displayName || authorProfile?.name || `${notification.pubkey.slice(0, 8)}...${notification.pubkey.slice(-4)}`} + + {#if authorProfile?.name && authorProfile?.displayName && authorProfile.name !== authorProfile.displayName} + + (@{authorProfile.name}) + + {/if} +
+ + {#if notification.content} +
+ {truncateContent(notification.content)} +
{/if} -
- - {#if notification.content} -
- {truncateContent(notification.content)} + +
+ + + {getNeventUrl(notification).slice(0, 16)}... +
- {/if} - -
- - - {getNeventUrl(notification).slice(0, 16)}... -
+ {/each} +
+ + {#if notifications.length > 10} +
+ Showing 10 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more.
- {/each} + {/if}
- - {#if notifications.length > 10} -
- Showing 10 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more. -
- {/if} -
+ {/if} {/if}
{/if} \ No newline at end of file From 49de089e9a4e3c66eafdec29a2594a46e90fb453 Mon Sep 17 00:00:00 2001 From: silberengel Date: Fri, 8 Aug 2025 23:21:56 +0200 Subject: [PATCH 14/29] relay info display in public message replies --- src/lib/components/Notifications.svelte | 240 +++++++++++++++++++- src/lib/components/RelayInfoDisplay.svelte | 92 ++++++++ src/lib/components/RelayInfoList.svelte | 169 ++++++++++++++ src/lib/utils/kind24_utils.ts | 252 +++++++++++++++++++++ src/lib/utils/relay_info_service.ts | 166 ++++++++++++++ 5 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/RelayInfoDisplay.svelte create mode 100644 src/lib/components/RelayInfoList.svelte create mode 100644 src/lib/utils/kind24_utils.ts create mode 100644 src/lib/utils/relay_info_service.ts diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 867b881..062ecd4 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -11,6 +11,9 @@ import { get } from "svelte/store"; import { nip19 } from "nostr-tools"; import { communityRelays, localRelays } from "$lib/consts"; + import { createKind24Reply, getKind24RelaySet } from "$lib/utils/kind24_utils"; + import RelayDisplay from "$lib/components/RelayDisplay.svelte"; + import RelayInfoList from "$lib/components/RelayInfoList.svelte"; const { event } = $props<{ event: NDKEvent }>(); @@ -23,6 +26,14 @@ let notificationMode = $state<"to-me" | "from-me" | "public-messages">("to-me"); let authorProfiles = $state>(new Map()); let filteredByUser = $state(null); + let replyContent = $state(""); + let replyingTo = $state(null); + let isReplying = $state(false); + let originalMessage = $state(null); + let replyingToMessageId = $state(null); + let replyRelays = $state([]); + let senderOutboxRelays = $state([]); + let recipientInboxRelays = $state([]); // Derived state for filtered messages let filteredMessages = $derived.by(() => { @@ -85,6 +96,19 @@ return content.slice(0, maxLength) + "..."; } + function renderContentWithLinks(content: string): string { + console.log("[Notifications] Rendering content:", content); + + // Parse markdown links [text](url) and convert to HTML + let rendered = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Also handle the new quote format: "> LINK: nevent://..." and convert to button + rendered = rendered.replace(/> LINK: (nevent:\/\/[^\s\n]+)/g, '> '); + + console.log("[Notifications] Rendered content:", rendered); + return rendered; + } + function getNotificationType(event: NDKEvent): string { switch (event.kind) { case 1: return "Reply"; @@ -109,6 +133,141 @@ filteredByUser = null; } + // AI-NOTE: Reply functionality for kind 24 messages + async function startReply(pubkey: string, messageEvent?: NDKEvent) { + replyingTo = pubkey; + isReplying = true; + replyContent = ""; + replyingToMessageId = messageEvent?.id || null; + // Store the original message for q tag + originalMessage = messageEvent || null; + // Clear previous relay information + replyRelays = []; + senderOutboxRelays = []; + recipientInboxRelays = []; + + // Immediately fetch relay information for this recipient + await getReplyRelays(); + } + + function cancelReply() { + replyingTo = null; + isReplying = false; + replyContent = ""; + replyingToMessageId = null; + replyRelays = []; + senderOutboxRelays = []; + recipientInboxRelays = []; + } + + async function sendReply() { + if (!replyingTo || !replyContent.trim()) return; + + try { + // Find the original message being replied to + const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId); + const result = await createKind24Reply(replyContent, replyingTo, originalMessage); + + if (result.success) { + // Store relay information for display + replyRelays = result.relays || []; + + // Update the inbox/outbox arrays to match the actual relays being used + // Keep only the top 3 that are actually in the reply relay set + const replyRelaySet = new Set(replyRelays); + senderOutboxRelays = senderOutboxRelays + .filter(relay => replyRelaySet.has(relay)) + .slice(0, 3); + recipientInboxRelays = recipientInboxRelays + .filter(relay => replyRelaySet.has(relay)) + .slice(0, 3); + + // Clear reply state + replyingTo = null; + isReplying = false; + replyContent = ""; + replyingToMessageId = null; + // Optionally refresh messages + await fetchPublicMessages(); + } else { + console.error("Failed to send reply:", result.error); + // You could show an error message to the user here + } + } catch (error) { + console.error("Error sending reply:", error); + } + } + + // Function to get relay information before sending + async function getReplyRelays() { + if (!replyingTo) return; + + try { + const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId); + + // Get sender's outbox relays and recipient's inbox relays + const ndk = get(ndkInstance); + if (ndk?.activeUser) { + // Get sender's outbox relays + const senderUser = ndk.activeUser; + const senderRelayList = await ndk.fetchEvent({ + kinds: [10002], + authors: [senderUser.pubkey], + }); + + if (senderRelayList) { + senderOutboxRelays = senderRelayList.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .map(tag => tag[1]) + .slice(0, 3); // Limit to top 3 outbox relays + } + + // Get recipient's inbox relays + const recipientUser = ndk.getUser({ pubkey: replyingTo }); + const recipientRelayList = await ndk.fetchEvent({ + kinds: [10002], + authors: [replyingTo], + }); + + if (recipientRelayList) { + recipientInboxRelays = recipientRelayList.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .map(tag => tag[1]) + .slice(0, 3); // Limit to top 3 inbox relays + } + } + + // If we have content, use the actual reply function + if (replyContent.trim()) { + const result = await createKind24Reply(replyContent, replyingTo, originalMessage); + replyRelays = result.relays || []; + } else { + // If no content yet, just get the relay set for this recipient + const result = await getKind24RelaySet($userStore.pubkey || '', replyingTo); + replyRelays = result || []; + + // Update the inbox/outbox arrays to match the actual relays being used + // Keep only the top 3 that are actually in the reply relay set + const replyRelaySet = new Set(replyRelays); + senderOutboxRelays = senderOutboxRelays + .filter(relay => replyRelaySet.has(relay)) + .slice(0, 3); + recipientInboxRelays = recipientInboxRelays + .filter(relay => replyRelaySet.has(relay)) + .slice(0, 3); + + console.log('[Notifications] Got relay set:', result); + console.log('[Notifications] Filtered sender outbox relays:', senderOutboxRelays); + console.log('[Notifications] Filtered recipient inbox relays:', recipientInboxRelays); + } + } catch (error) { + console.error("Error getting relay information:", error); + replyRelays = []; + senderOutboxRelays = []; + recipientInboxRelays = []; + } + } + // AI-NOTE: Simplified profile fetching with better error handling async function fetchAuthorProfiles(events: NDKEvent[]) { const uniquePubkeys = new Set(); @@ -321,6 +480,13 @@ authorProfiles.clear(); } }); + + // Fetch relay information when reply content changes (for updates) + $effect(() => { + if (isReplying && replyingTo && replyContent.trim() && replyRelays.length === 0) { + getReplyRelays(); + } + }); {#if isOwnProfile && $userStore.signedIn} @@ -400,7 +566,19 @@ {#if !isFromUser} -
+
+ + +
+ + + {#if isReplying && replyingToMessageId === message.id} + {@const recipientProfile = authorProfiles.get(message.pubkey)} +
+
+ + Replying to: {recipientProfile?.displayName || recipientProfile?.name || `${message.pubkey.slice(0, 8)}...${message.pubkey.slice(-4)}`} + + +
+
+ + +
+ + +
+ {#if replyRelays.length > 0} + {@const debugInfo = console.log('[Notifications] Rendering RelayInfoList with:', { replyRelays, recipientInboxRelays, senderOutboxRelays })} + + {:else} +
+
+ Loading relay information... +
+ {/if} +
+
+ {/if}
{/each}
diff --git a/src/lib/components/RelayInfoDisplay.svelte b/src/lib/components/RelayInfoDisplay.svelte new file mode 100644 index 0000000..c72dc24 --- /dev/null +++ b/src/lib/components/RelayInfoDisplay.svelte @@ -0,0 +1,92 @@ + + +
+ {#if showIcon && relayIcon} + Relay icon (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else if showIcon} + +
+ + + +
+ {/if} + +
+ {#if showName} + + {isLoading ? 'Loading...' : displayName} + + {/if} + + {#if showType} + + {relayType} + + {/if} +
+ + {#if error} + + ⚠️ + + {/if} +
diff --git a/src/lib/components/RelayInfoList.svelte b/src/lib/components/RelayInfoList.svelte new file mode 100644 index 0000000..0443453 --- /dev/null +++ b/src/lib/components/RelayInfoList.svelte @@ -0,0 +1,169 @@ + + +
+ {#if showLabels && !compact} + {@const categorizedCount = categorizedRelays().length} + {@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorizedRelays())} +
+ Publishing to {categorizedCount} relay(s): +
+ {/if} + + {#if isLoading} +
+
+ Loading relay info... +
+ {:else} + {@const categorized = categorizedRelays()} + {@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorized)} + +
+ {#each categorized as { relay, category, label }} +
+
+ + {relay} + + {#if category === 'both'} + + common relay + + {/if} +
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/utils/kind24_utils.ts b/src/lib/utils/kind24_utils.ts new file mode 100644 index 0000000..e3b6e14 --- /dev/null +++ b/src/lib/utils/kind24_utils.ts @@ -0,0 +1,252 @@ +import { get } from "svelte/store"; +import { ndkInstance } from "../ndk"; +import { userStore } from "../stores/userStore"; +import { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; +import type NDK from "@nostr-dev-kit/ndk"; +import { nip19 } from "nostr-tools"; + +/** + * Fetches user's outbox relays from NIP-65 relay list + * @param ndk NDK instance + * @param user User to fetch outbox relays for + * @returns Promise that resolves to array of outbox relay URLs + */ +async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise { + try { + console.debug('[kind24_utils] Fetching outbox relays for user:', user.pubkey); + const relayList = await ndk.fetchEvent( + { + kinds: [10002], + authors: [user.pubkey], + } + ); + + if (!relayList) { + console.debug('[kind24_utils] No relay list found for user'); + return []; + } + + console.debug('[kind24_utils] Found relay list event:', relayList.id); + console.debug('[kind24_utils] Relay list tags:', relayList.tags); + + const outboxRelays: string[] = []; + relayList.tags.forEach((tag) => { + console.debug('[kind24_utils] Processing tag:', tag); + if (tag[0] === 'r' && tag[1]) { + // NIP-65: r tags with optional inbox/outbox markers + const marker = tag[2]; + if (!marker || marker === 'outbox' || marker === 'inbox') { + // If no marker or marker is 'outbox', it's a outbox relay + // If marker is 'inbox', it's also a outbox relay (NIP-65 allows both) + outboxRelays.push(tag[1]); + console.debug('[kind24_utils] Added outbox relay:', tag[1]); + } + } + }); + + console.debug('[kind24_utils] Final outbox relays:', outboxRelays); + return outboxRelays; + } catch (error) { + console.info('[kind24_utils] Error fetching user outbox relays:', error); + return []; + } +} + +/** + * Fetches user's inbox relays from NIP-65 relay list + * @param ndk NDK instance + * @param user User to fetch inbox relays for + * @returns Promise that resolves to array of inbox relay URLs + */ +async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise { + try { + console.debug('[kind24_utils] Fetching inbox relays for user:', user.pubkey); + const relayList = await ndk.fetchEvent( + { + kinds: [10002], + authors: [user.pubkey], + } + ); + + if (!relayList) { + console.debug('[kind24_utils] No relay list found for user'); + return []; + } + + console.debug('[kind24_utils] Found relay list event:', relayList.id); + console.debug('[kind24_utils] Relay list tags:', relayList.tags); + + const inboxRelays: string[] = []; + relayList.tags.forEach((tag) => { + console.debug('[kind24_utils] Processing tag:', tag); + if (tag[0] === 'r' && tag[1]) { + // NIP-65: r tags with optional inbox/outbox markers + const marker = tag[2]; + if (!marker || marker === 'inbox' || marker === 'outbox') { + // If no marker or marker is 'inbox', it's a inbox relay + // If marker is 'outbox', it's also a inbox relay (NIP-65 allows both) + inboxRelays.push(tag[1]); + console.debug('[kind24_utils] Added inbox relay:', tag[1]); + } + } + }); + + console.debug('[kind24_utils] Final inbox relays:', inboxRelays); + return inboxRelays; + } catch (error) { + console.info('[kind24_utils] Error fetching user inbox relays:', error); + return []; + } +} + +/** + * Creates a kind 24 public message reply according to NIP-A4 + * @param content The message content + * @param recipientPubkey The recipient's pubkey + * @param originalEvent The original event being replied to (optional) + * @returns Promise resolving to publish result with relay information + */ +export async function createKind24Reply( + content: string, + recipientPubkey: string, + originalEvent?: NDKEvent +): Promise<{ success: boolean; eventId?: string; error?: string; relays?: string[] }> { + const ndk = get(ndkInstance); + if (!ndk?.activeUser) { + return { success: false, error: "Not logged in" }; + } + + if (!content.trim()) { + return { success: false, error: "Message content cannot be empty" }; + } + + try { + // Get sender's outbox relays (NIP-65) + const senderoutboxRelays = await getUseroutboxRelays(ndk, ndk.activeUser); + + // Get recipient's inbox relays (NIP-65) + const recipientUser = ndk.getUser({ pubkey: recipientPubkey }); + const recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser); + + // According to NIP-A4: Messages MUST be sent to the NIP-65 inbox relays of each receiver + // and the outbox relay of the sender + const targetRelays = [...new Set([...senderoutboxRelays, ...recipientinboxRelays])]; + + // Prioritize common relays between sender and recipient for better privacy + const commonRelays = senderoutboxRelays.filter(relay => + recipientinboxRelays.includes(relay) + ); + const senderOnlyRelays = senderoutboxRelays.filter(relay => + !recipientinboxRelays.includes(relay) + ); + const recipientOnlyRelays = recipientinboxRelays.filter(relay => + !senderoutboxRelays.includes(relay) + ); + + // Prioritize: common relays first, then sender outbox, then recipient inbox + const prioritizedRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays]; + + if (prioritizedRelays.length === 0) { + return { success: false, error: "No relays available for publishing" }; + } + + // Create the kind 24 event + const event = new NDKEvent(ndk); + event.kind = 24; + + // Build content with quoted message if replying + let finalContent = content; + if (originalEvent) { + // Use multiple relays for better discoverability + const nevent = nip19.neventEncode({ + id: originalEvent.id, + relays: prioritizedRelays.slice(0, 3) // Use first 3 relays + }); + const quotedContent = originalEvent.content ? originalEvent.content.slice(0, 200) : "No content"; + // Use a more visible quote format with a clickable link + finalContent = `> QUOTED: ${quotedContent}\n> LINK: ${nevent}\n\n${content}`; + console.log("[kind24_utils] Reply content:", finalContent); + } + + event.content = finalContent; + event.created_at = Math.floor(Date.now() / 1000); + + // Add p tag for recipient with relay URL + const tags: string[][] = [ + ["p", recipientPubkey, prioritizedRelays[0]] // Use first relay as primary + ]; + + // Add q tag if replying to an original event + if (originalEvent) { + const nevent = nip19.neventEncode({ + id: originalEvent.id, + relays: prioritizedRelays.slice(0, 3) // Use first 3 relays + }); + tags.push(["q", nevent, prioritizedRelays[0]]); + } + + event.tags = tags; + event.pubkey = ndk.activeUser.pubkey; + + // Sign the event + await event.sign(); + + // Publish to relays + const relaySet = NDKRelaySet.fromRelayUrls(prioritizedRelays, ndk); + const publishedToRelays = await event.publish(relaySet); + + if (publishedToRelays.size > 0) { + return { success: true, eventId: event.id, relays: prioritizedRelays }; + } else { + return { success: false, error: "Failed to publish to any relays", relays: prioritizedRelays }; + } + } catch (error) { + console.error("[kind24_utils] Error creating kind 24 reply:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }; + } +} + +/** + * Gets optimal relay set for kind 24 messages between two users + * @param senderPubkey The sender's pubkey + * @param recipientPubkey The recipient's pubkey + * @returns Promise resolving to relay URLs prioritized by commonality + */ +export async function getKind24RelaySet( + senderPubkey: string, + recipientPubkey: string +): Promise { + const ndk = get(ndkInstance); + if (!ndk) { + throw new Error("NDK not available"); + } + + // Get sender's outbox relays (NIP-65) + const senderUser = ndk.getUser({ pubkey: senderPubkey }); + const senderoutboxRelays = await getUseroutboxRelays(ndk, senderUser); + + // Get recipient's inbox relays (NIP-65) + const recipientUser = ndk.getUser({ pubkey: recipientPubkey }); + const recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser); + + // According to NIP-A4: Messages MUST be sent to the NIP-65 inbox relays of each receiver + // and the outbox relay of the sender + const targetRelays = [...new Set([...senderoutboxRelays, ...recipientinboxRelays])]; + + // Prioritize common relays between sender and recipient for better privacy + const commonRelays = senderoutboxRelays.filter((relay: string) => + recipientinboxRelays.includes(relay) + ); + const senderOnlyRelays = senderoutboxRelays.filter((relay: string) => + !recipientinboxRelays.includes(relay) + ); + const recipientOnlyRelays = recipientinboxRelays.filter((relay: string) => + !senderoutboxRelays.includes(relay) + ); + + // Prioritize: common relays first, then sender outbox, then recipient inbox + return [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays]; +} diff --git a/src/lib/utils/relay_info_service.ts b/src/lib/utils/relay_info_service.ts new file mode 100644 index 0000000..8b978a0 --- /dev/null +++ b/src/lib/utils/relay_info_service.ts @@ -0,0 +1,166 @@ +/** + * Simplifies a URL by removing protocol and common prefixes + * @param url The URL to simplify + * @returns Simplified URL string + */ +function simplifyUrl(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.hostname + (urlObj.port ? `:${urlObj.port}` : ''); + } catch { + // If URL parsing fails, return the original string + return url; + } +} + +export interface RelayInfo { + name?: string; + description?: string; + icon?: string; + pubkey?: string; + contact?: string; + supported_nips?: number[]; + software?: string; + version?: string; + tags?: string[]; + payments_url?: string; + limitation?: { + auth_required?: boolean; + payment_required?: boolean; + }; +} + +export interface RelayInfoWithMetadata extends RelayInfo { + url: string; + shortUrl: string; + hasNip11: boolean; + triedNip11: boolean; +} + +/** + * Fetches relay information using NIP-11 + * @param url The relay URL to fetch info for + * @returns Promise resolving to relay info or undefined if failed + */ +export async function fetchRelayInfo(url: string): Promise { + try { + // Convert WebSocket URL to HTTP URL for NIP-11 + const httpUrl = url.replace('ws://', 'http://').replace('wss://', 'https://'); + + const response = await fetch(httpUrl, { + headers: { + 'Accept': 'application/nostr+json', + 'User-Agent': 'Alexandria/1.0' + }, + // Add timeout to prevent hanging + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + console.warn(`[RelayInfo] HTTP ${response.status} for ${url}`); + return { + url, + shortUrl: simplifyUrl(url), + hasNip11: false, + triedNip11: true + }; + } + + const relayInfo = await response.json() as RelayInfo; + + return { + ...relayInfo, + url, + shortUrl: simplifyUrl(url), + hasNip11: Object.keys(relayInfo).length > 0, + triedNip11: true + }; + } catch (error) { + console.warn(`[RelayInfo] Failed to fetch info for ${url}:`, error); + return { + url, + shortUrl: simplifyUrl(url), + hasNip11: false, + triedNip11: true + }; + } +} + +/** + * Fetches relay information for multiple relays in parallel + * @param urls Array of relay URLs to fetch info for + * @returns Promise resolving to array of relay info objects + */ +export async function fetchRelayInfos(urls: string[]): Promise { + if (urls.length === 0) { + return []; + } + + const promises = urls.map(url => fetchRelayInfo(url)); + const results = await Promise.allSettled(promises); + + return results + .map(result => result.status === 'fulfilled' ? result.value : undefined) + .filter((info): info is RelayInfoWithMetadata => info !== undefined); +} + +/** + * Gets relay type label based on relay URL and info + * @param relayUrl The relay URL + * @param relayInfo Optional relay info + * @returns String describing the relay type + */ +export function getRelayTypeLabel(relayUrl: string, relayInfo?: RelayInfoWithMetadata): string { + // Check if it's a local relay + if (relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')) { + return 'Local'; + } + + // Check if it's a community relay + if (relayUrl.includes('nostr.band') || relayUrl.includes('noswhere.com') || + relayUrl.includes('damus.io') || relayUrl.includes('nostr.wine')) { + return 'Community'; + } + + // Check if it's a user's relay (likely inbox/outbox) + if (relayUrl.includes('relay.nsec.app') || relayUrl.includes('relay.snort.social')) { + return 'User'; + } + + // Use relay name if available + if (relayInfo?.name) { + return relayInfo.name; + } + + // Fallback to domain + try { + const domain = new URL(relayUrl).hostname; + return domain.replace('www.', ''); + } catch { + return 'Unknown'; + } +} + +/** + * Gets relay icon URL or fallback + * @param relayInfo Relay info object + * @param relayUrl Relay URL as fallback + * @returns Icon URL or undefined + */ +export function getRelayIcon(relayInfo?: RelayInfoWithMetadata, relayUrl?: string): string | undefined { + if (relayInfo?.icon) { + return relayInfo.icon; + } + + // Generate favicon URL from relay URL + if (relayUrl) { + try { + const url = new URL(relayUrl); + return `${url.protocol}//${url.hostname}/favicon.ico`; + } catch { + // Invalid URL, return undefined + } + } + + return undefined; +} From b6c30352e215fdd212e138d3259cd27e57ab1d77 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 00:08:22 +0200 Subject: [PATCH 15/29] gray bar navigation --- src/lib/components/Notifications.svelte | 123 +++++++++++++++++------- src/lib/components/RelayInfoList.svelte | 34 +------ src/lib/utils/kind24_utils.ts | 28 ++---- 3 files changed, 103 insertions(+), 82 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 062ecd4..1604532 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -17,6 +17,22 @@ const { event } = $props<{ event: NDKEvent }>(); + // Handle navigation events from quoted messages + $effect(() => { + if (typeof window !== 'undefined') { + const handleJumpToMessage = (e: Event) => { + const customEvent = e as CustomEvent; + jumpToMessageInFeed(customEvent.detail); + }; + + window.addEventListener('jump-to-message', handleJumpToMessage); + + return () => { + window.removeEventListener('jump-to-message', handleJumpToMessage); + }; + } + }); + // Component state let notifications = $state([]); let publicMessages = $state([]); @@ -76,7 +92,9 @@ function getNeventUrl(event: NDKEvent): string { const relays = getAvailableRelays(); - return neventEncode(event, relays); + const nevent = neventEncode(event, relays); + console.log('Generated nevent for event:', event.id, '→', nevent); + return nevent; } function formatDate(timestamp: number): string { @@ -97,15 +115,28 @@ } function renderContentWithLinks(content: string): string { - console.log("[Notifications] Rendering content:", content); - // Parse markdown links [text](url) and convert to HTML let rendered = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); - // Also handle the new quote format: "> LINK: nevent://..." and convert to button - rendered = rendered.replace(/> LINK: (nevent:\/\/[^\s\n]+)/g, '> '); + // Handle quote format and convert to small gray bars like Jumble + const patterns = [ + /> QUOTED: ([^•]*?) • LINK:\s*\n(nevent[^\s]*)/g, + /> QUOTED: ([^\n]*?)\n> LINK: (nevent[^\s]*)/g, + /> QUOTED: ([^•]*?) • LINK:\s*(nevent[^\s]*)/g, + ]; + + for (const pattern of patterns) { + const beforeReplace = rendered; + rendered = rendered.replace(pattern, (match, quotedText, neventUrl) => { + const encodedUrl = neventUrl.replace(/'/g, '''); + const cleanQuotedText = quotedText.trim(); + return `
${cleanQuotedText}
`; + }); + if (beforeReplace !== rendered) { + break; + } + } - console.log("[Notifications] Rendered content:", rendered); return rendered; } @@ -122,9 +153,43 @@ } function navigateToEvent(nevent: string) { + // Navigate to the events search page with this specific event goto(`/events?id=${nevent}`); } + function jumpToMessageInFeed(nevent: string) { + // Switch to public messages tab and scroll to the specific message + notificationMode = "public-messages"; + + // Try to find and scroll to the specific message + setTimeout(() => { + try { + // Decode the nevent to get the event ID + const decoded = nip19.decode(nevent); + if (decoded.type === 'nevent' && decoded.data.id) { + const eventId = decoded.data.id; + + // Find the message in our public messages + const targetMessage = publicMessages.find(msg => msg.id === eventId); + if (targetMessage) { + // Try to scroll to the element if it exists in the DOM + const element = document.querySelector(`[data-event-id="${eventId}"]`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Briefly highlight the message + element.classList.add('ring-2', 'ring-blue-500'); + setTimeout(() => { + element.classList.remove('ring-2', 'ring-blue-500'); + }, 2000); + } + } + } + } catch (error) { + console.warn('Failed to jump to message:', error); + } + }, 100); + } + function filterByUser(pubkey: string) { filteredByUser = filteredByUser === pubkey ? null : pubkey; } @@ -256,9 +321,7 @@ .filter(relay => replyRelaySet.has(relay)) .slice(0, 3); - console.log('[Notifications] Got relay set:', result); - console.log('[Notifications] Filtered sender outbox relays:', senderOutboxRelays); - console.log('[Notifications] Filtered recipient inbox relays:', recipientInboxRelays); + } } catch (error) { console.error("Error getting relay information:", error); @@ -545,7 +608,7 @@ {#each filteredMessages.slice(0, 20) as message} {@const authorProfile = authorProfiles.get(message.pubkey)} {@const isFromUser = message.pubkey === $userStore.pubkey} -
+
@@ -602,6 +665,13 @@ {message.created_at ? formatDate(message.created_at) : "Unknown date"} +
@@ -622,17 +692,7 @@
{/if} -
- - - {getNeventUrl(message).slice(0, 16)}... - -
+
@@ -676,7 +736,7 @@
{#if replyRelays.length > 0} - {@const debugInfo = console.log('[Notifications] Rendering RelayInfoList with:', { replyRelays, recipientInboxRelays, senderOutboxRelays })} + {notification.created_at ? formatDate(notification.created_at) : "Unknown date"} +
@@ -763,17 +830,7 @@
{/if} -
- - - {getNeventUrl(notification).slice(0, 16)}... - -
+
diff --git a/src/lib/components/RelayInfoList.svelte b/src/lib/components/RelayInfoList.svelte index 0443453..62d6b8b 100644 --- a/src/lib/components/RelayInfoList.svelte +++ b/src/lib/components/RelayInfoList.svelte @@ -25,18 +25,10 @@ label: string; }; - // AI-NOTE: Updated to show only top-3 inboxes and top-3 outboxes as intended + // Categorize relays by their function (inbox/outbox/both) const categorizedRelays = $derived(() => { const inbox = new Set(inboxRelays); const outbox = new Set(outboxRelays); - - console.log('[RelayInfoList] Categorizing relays:', { - relays: relays.length, - inboxRelays: inboxRelays.length, - outboxRelays: outboxRelays.length - }); - - // Create a map of all relays with their categories const relayCategories = new Map(); // Process inbox relays (up to top 3) @@ -58,29 +50,19 @@ } }); - // Only include relays that are actually in the top-3 lists - // This ensures we only show the intended top-3 inboxes and top-3 outboxes - const categorized = Array.from(relayCategories.values()); - console.log('[RelayInfoList] Categorized relays count:', categorized.length); - return categorized; + return Array.from(relayCategories.values()); }); - // Group by category + // Group by category for display const groupedRelays = $derived(() => { const categorized = categorizedRelays(); - console.log('[RelayInfoList] Grouping categorized relays'); - const groups = { + return { both: categorized.filter((r: CategorizedRelay) => r.category === 'both'), inbox: categorized.filter((r: CategorizedRelay) => r.category === 'inbox'), outbox: categorized.filter((r: CategorizedRelay) => r.category === 'outbox'), other: categorized.filter((r: CategorizedRelay) => r.category === 'other') }; - - console.log('[RelayInfoList] Grouped relays:', Object.fromEntries( - Object.entries(groups).map(([key, relays]) => [key, relays.length]) - )); - return groups; }); async function loadRelayInfos() { @@ -99,12 +81,6 @@ // Load relay info when categorized relays change $effect(() => { const categorized = categorizedRelays(); - console.log('[RelayInfoList] Categorized relays changed:', { - total: categorized.length, - byCategory: Object.fromEntries( - Object.entries(groupedRelays()).map(([key, relays]) => [key, relays.length]) - ) - }); if (categorized.length > 0) { loadRelayInfos(); } @@ -134,7 +110,6 @@
{#if showLabels && !compact} {@const categorizedCount = categorizedRelays().length} - {@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorizedRelays())}
Publishing to {categorizedCount} relay(s):
@@ -147,7 +122,6 @@
{:else} {@const categorized = categorizedRelays()} - {@const debugCategorized = console.log('[RelayInfoList] Debug - categorized relays:', categorized)}
{#each categorized as { relay, category, label }} diff --git a/src/lib/utils/kind24_utils.ts b/src/lib/utils/kind24_utils.ts index e3b6e14..62a27d0 100644 --- a/src/lib/utils/kind24_utils.ts +++ b/src/lib/utils/kind24_utils.ts @@ -13,7 +13,7 @@ import { nip19 } from "nostr-tools"; */ async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise { try { - console.debug('[kind24_utils] Fetching outbox relays for user:', user.pubkey); + const relayList = await ndk.fetchEvent( { kinds: [10002], @@ -22,16 +22,11 @@ async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise { ); if (!relayList) { - console.debug('[kind24_utils] No relay list found for user'); return []; } - console.debug('[kind24_utils] Found relay list event:', relayList.id); - console.debug('[kind24_utils] Relay list tags:', relayList.tags); - const outboxRelays: string[] = []; relayList.tags.forEach((tag) => { - console.debug('[kind24_utils] Processing tag:', tag); if (tag[0] === 'r' && tag[1]) { // NIP-65: r tags with optional inbox/outbox markers const marker = tag[2]; @@ -39,15 +34,15 @@ async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise { // If no marker or marker is 'outbox', it's a outbox relay // If marker is 'inbox', it's also a outbox relay (NIP-65 allows both) outboxRelays.push(tag[1]); - console.debug('[kind24_utils] Added outbox relay:', tag[1]); + } } }); - console.debug('[kind24_utils] Final outbox relays:', outboxRelays); + return outboxRelays; } catch (error) { - console.info('[kind24_utils] Error fetching user outbox relays:', error); + return []; } } @@ -60,7 +55,7 @@ async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise { */ async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise { try { - console.debug('[kind24_utils] Fetching inbox relays for user:', user.pubkey); + const relayList = await ndk.fetchEvent( { kinds: [10002], @@ -69,16 +64,11 @@ async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise { ); if (!relayList) { - console.debug('[kind24_utils] No relay list found for user'); return []; } - console.debug('[kind24_utils] Found relay list event:', relayList.id); - console.debug('[kind24_utils] Relay list tags:', relayList.tags); - const inboxRelays: string[] = []; relayList.tags.forEach((tag) => { - console.debug('[kind24_utils] Processing tag:', tag); if (tag[0] === 'r' && tag[1]) { // NIP-65: r tags with optional inbox/outbox markers const marker = tag[2]; @@ -86,15 +76,15 @@ async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise { // If no marker or marker is 'inbox', it's a inbox relay // If marker is 'outbox', it's also a inbox relay (NIP-65 allows both) inboxRelays.push(tag[1]); - console.debug('[kind24_utils] Added inbox relay:', tag[1]); + } } }); - console.debug('[kind24_utils] Final inbox relays:', inboxRelays); + return inboxRelays; } catch (error) { - console.info('[kind24_utils] Error fetching user inbox relays:', error); + return []; } } @@ -165,7 +155,7 @@ export async function createKind24Reply( const quotedContent = originalEvent.content ? originalEvent.content.slice(0, 200) : "No content"; // Use a more visible quote format with a clickable link finalContent = `> QUOTED: ${quotedContent}\n> LINK: ${nevent}\n\n${content}`; - console.log("[kind24_utils] Reply content:", finalContent); + } event.content = finalContent; From dab4995dcf717c58478e9f7d738d886721f19ddc Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 00:21:24 +0200 Subject: [PATCH 16/29] moved notifications to the top --- src/lib/components/EventDetails.svelte | 8 +++++--- src/lib/components/Notifications.svelte | 12 +++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/components/EventDetails.svelte b/src/lib/components/EventDetails.svelte index dcdd0eb..6e6712a 100644 --- a/src/lib/components/EventDetails.svelte +++ b/src/lib/components/EventDetails.svelte @@ -401,6 +401,11 @@ {/if} + + {#if event.kind === 0} + + {/if} +
{#if toNpub(event.pubkey)} - - - {/if} diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 1604532..37cf746 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -92,9 +92,7 @@ function getNeventUrl(event: NDKEvent): string { const relays = getAvailableRelays(); - const nevent = neventEncode(event, relays); - console.log('Generated nevent for event:', event.id, '→', nevent); - return nevent; + return neventEncode(event, relays); } function formatDate(timestamp: number): string { @@ -605,7 +603,7 @@
{/if}
- {#each filteredMessages.slice(0, 20) as message} + {#each filteredMessages.slice(0, 100) as message} {@const authorProfile = authorProfiles.get(message.pubkey)} {@const isFromUser = message.pubkey === $userStore.pubkey}
@@ -772,7 +770,7 @@ {:else}
- {#each notifications.slice(0, 10) as notification} + {#each notifications.slice(0, 100) as notification} {@const authorProfile = authorProfiles.get(notification.pubkey)}
@@ -837,9 +835,9 @@ {/each}
- {#if notifications.length > 10} + {#if notifications.length > 100}
- Showing 10 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more. + Showing 100 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more.
{/if}
From eb4c19a9d720171fe9cf1cb8975dafc87c11e58b Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 01:55:19 +0200 Subject: [PATCH 17/29] fixed multi-recipients --- src/lib/components/Notifications.svelte | 33 +++++++++++++------------ 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 37cf746..4488952 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -14,6 +14,9 @@ import { createKind24Reply, getKind24RelaySet } from "$lib/utils/kind24_utils"; import RelayDisplay from "$lib/components/RelayDisplay.svelte"; import RelayInfoList from "$lib/components/RelayInfoList.svelte"; + import { Modal, Button } from "flowbite-svelte"; + import { searchProfiles } from "$lib/utils/search_utility"; + import type { NostrProfile } from "$lib/utils/search_types"; const { event } = $props<{ event: NDKEvent }>(); @@ -570,7 +573,7 @@
{#if loading} -
+
Loading {notificationMode === "public-messages" ? "public messages" : "notifications"}... @@ -586,9 +589,9 @@

No public messages found.

{:else} -
+
{#if filteredByUser} -
+
Filtered by user: {authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || `${filteredByUser.slice(0, 8)}...${filteredByUser.slice(-4)}`} @@ -602,11 +605,11 @@
{/if} -
+
{#each filteredMessages.slice(0, 100) as message} {@const authorProfile = authorProfiles.get(message.pubkey)} {@const isFromUser = message.pubkey === $userStore.pubkey} -
+
@@ -752,11 +755,11 @@
{/if}
- {/each} + {/each}
{#if filteredMessages.length > 20} -
+
Showing 20 of {filteredMessages.length} messages {filteredByUser ? `(filtered)` : ''}. Scroll to see more.
{/if} @@ -768,11 +771,10 @@

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

{:else} -
-
- {#each notifications.slice(0, 100) as notification} - {@const authorProfile = authorProfiles.get(notification.pubkey)} -
+
+ {#each notifications.slice(0, 100) as notification} + {@const authorProfile = authorProfiles.get(notification.pubkey)} +
@@ -831,12 +833,11 @@
-
- {/each} -
+
+ {/each} {#if notifications.length > 100} -
+
Showing 100 of {notifications.length} notifications {notificationMode === "to-me" ? "received" : "sent"}. Scroll to see more.
{/if} From 49752931173ec91000dc1846f24a08be7c3c22a3 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 07:49:55 +0200 Subject: [PATCH 18/29] made the kind 24 public messages ephemeral for 4 weeks --- src/lib/consts.ts | 2 ++ src/lib/utils/nostrEventService.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 29f4502..f141e7b 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -51,5 +51,7 @@ export enum FeedType { UserRelays = "user", } +export const EXPIRATION_DURATION = 28 * 24 * 60 * 60; // 4 weeks in seconds + export const loginStorageKey = "alexandria/login/pubkey"; export const feedTypeStorageKey = "alexandria/feed/type"; diff --git a/src/lib/utils/nostrEventService.ts b/src/lib/utils/nostrEventService.ts index cdea5e1..459275c 100644 --- a/src/lib/utils/nostrEventService.ts +++ b/src/lib/utils/nostrEventService.ts @@ -3,6 +3,7 @@ import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils.ts"; import { get } from "svelte/store"; import { goto } from "$app/navigation"; import { EVENT_KINDS, TIME_CONSTANTS } from "./search_constants.ts"; +import { EXPIRATION_DURATION } from "../consts.ts"; import { ndkInstance } from "../ndk.ts"; import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; @@ -320,12 +321,19 @@ export async function createSignedEvent( ): Promise<{ id: string; sig: string; event: any }> { const prefixedContent = prefixNostrAddresses(content); + // Add expiration tag for kind 24 events (NIP-40) + const finalTags = [...tags]; + if (kind === 24) { + const expirationTimestamp = Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR) + EXPIRATION_DURATION; + finalTags.push(["expiration", String(expirationTimestamp)]); + } + const eventToSign = { kind: Number(kind), created_at: Number( Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR), ), - tags: tags.map((tag) => [ + tags: finalTags.map((tag) => [ String(tag[0]), String(tag[1]), String(tag[2] || ""), From 49e8293fbeae40f61b789abefefa5731fc94d122 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 08:01:56 +0200 Subject: [PATCH 19/29] unified the kind24 publishing --- src/lib/utils/kind24_utils.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/lib/utils/kind24_utils.ts b/src/lib/utils/kind24_utils.ts index 62a27d0..edf362f 100644 --- a/src/lib/utils/kind24_utils.ts +++ b/src/lib/utils/kind24_utils.ts @@ -4,6 +4,7 @@ import { userStore } from "../stores/userStore"; import { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk"; import { nip19 } from "nostr-tools"; +import { createSignedEvent } from "./nostrEventService.ts"; /** * Fetches user's outbox relays from NIP-65 relay list @@ -140,10 +141,6 @@ export async function createKind24Reply( return { success: false, error: "No relays available for publishing" }; } - // Create the kind 24 event - const event = new NDKEvent(ndk); - event.kind = 24; - // Build content with quoted message if replying let finalContent = content; if (originalEvent) { @@ -155,13 +152,9 @@ export async function createKind24Reply( const quotedContent = originalEvent.content ? originalEvent.content.slice(0, 200) : "No content"; // Use a more visible quote format with a clickable link finalContent = `> QUOTED: ${quotedContent}\n> LINK: ${nevent}\n\n${content}`; - } - event.content = finalContent; - event.created_at = Math.floor(Date.now() / 1000); - - // Add p tag for recipient with relay URL + // Build tags for the kind 24 event const tags: string[][] = [ ["p", recipientPubkey, prioritizedRelays[0]] // Use first relay as primary ]; @@ -175,11 +168,16 @@ export async function createKind24Reply( tags.push(["q", nevent, prioritizedRelays[0]]); } - event.tags = tags; - event.pubkey = ndk.activeUser.pubkey; - - // Sign the event - await event.sign(); + // Create and sign the event using the unified function (includes expiration tag) + const { event: signedEventData } = await createSignedEvent( + finalContent, + ndk.activeUser.pubkey, + 24, + tags + ); + + // Create NDKEvent from the signed event data + const event = new NDKEvent(ndk, signedEventData); // Publish to relays const relaySet = NDKRelaySet.fromRelayUrls(prioritizedRelays, ndk); From de183ed6ffd7cfe9af3f305eec52b3b19c09cf19 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 08:48:02 +0200 Subject: [PATCH 20/29] reinstate missing New Message button --- src/lib/components/Notifications.svelte | 754 ++++++++++++++++++------ src/lib/utils/profile_search.ts | 27 +- 2 files changed, 589 insertions(+), 192 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 4488952..45ee83e 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -17,6 +17,7 @@ import { Modal, Button } from "flowbite-svelte"; import { searchProfiles } from "$lib/utils/search_utility"; import type { NostrProfile } from "$lib/utils/search_types"; + import { PlusOutline } from "flowbite-svelte-icons"; const { event } = $props<{ event: NDKEvent }>(); @@ -45,14 +46,26 @@ let notificationMode = $state<"to-me" | "from-me" | "public-messages">("to-me"); let authorProfiles = $state>(new Map()); let filteredByUser = $state(null); - let replyContent = $state(""); - let replyingTo = $state(null); - let isReplying = $state(false); - let originalMessage = $state(null); - let replyingToMessageId = $state(null); - let replyRelays = $state([]); - let senderOutboxRelays = $state([]); - let recipientInboxRelays = $state([]); + + + // New Message Modal state + let showNewMessageModal = $state(false); + let newMessageContent = $state(""); + let selectedRecipients = $state([]); + let newMessageRelays = $state([]); + let isComposingMessage = $state(false); + let replyToMessage = $state(null); + let quotedContent = $state(""); + + // Recipient Selection Modal state + let showRecipientModal = $state(false); + let recipientSearch = $state(""); + let recipientResults = $state([]); + let recipientLoading = $state(false); + let recipientSearchInput = $state(); + let recipientSearchTimeout: ReturnType | null = null; + let recipientCommunityStatus: Record = $state({}); + let isRecipientSearching = $state(false); // Derived state for filtered messages let filteredMessages = $derived.by(() => { @@ -121,9 +134,10 @@ // Handle quote format and convert to small gray bars like Jumble const patterns = [ - /> QUOTED: ([^•]*?) • LINK:\s*\n(nevent[^\s]*)/g, - /> QUOTED: ([^\n]*?)\n> LINK: (nevent[^\s]*)/g, - /> QUOTED: ([^•]*?) • LINK:\s*(nevent[^\s]*)/g, + /> QUOTED: ([^•]*?) • LINK:\s*\n((?:nostr:)?nevent[^\s]*)/g, + /> QUOTED: ([^\n]*?)\n> LINK: ((?:nostr:)?nevent[^\s]*)/g, + /> QUOTED: ([^•]*?) • LINK:\s*((?:nostr:)?nevent[^\s]*)/g, + /> QUOTED: ([^•]*?) • LINK: ((?:nostr:)?nevent[^\s]*)/g, // Without optional whitespace ]; for (const pattern of patterns) { @@ -199,139 +213,301 @@ filteredByUser = null; } - // AI-NOTE: Reply functionality for kind 24 messages - async function startReply(pubkey: string, messageEvent?: NDKEvent) { - replyingTo = pubkey; - isReplying = true; - replyContent = ""; - replyingToMessageId = messageEvent?.id || null; - // Store the original message for q tag - originalMessage = messageEvent || null; - // Clear previous relay information - replyRelays = []; - senderOutboxRelays = []; - recipientInboxRelays = []; + + + // AI-NOTE: New Message Modal Functions + function openNewMessageModal(messageToReplyTo?: NDKEvent) { + showNewMessageModal = true; + newMessageContent = ""; + selectedRecipients = []; + newMessageRelays = []; + isComposingMessage = false; + replyToMessage = messageToReplyTo || null; - // Immediately fetch relay information for this recipient - await getReplyRelays(); + // If replying, set up the quote and pre-select all original recipients plus sender + if (messageToReplyTo) { + // Store clean content for UI display (no markdown formatting) + quotedContent = messageToReplyTo.content.length > 200 + ? messageToReplyTo.content.slice(0, 200) + "..." + : messageToReplyTo.content; + + // Collect all recipients: original sender + all p-tag recipients + const recipientPubkeys = new Set(); + + // Add the original sender + recipientPubkeys.add(messageToReplyTo.pubkey); + + // Add all p-tag recipients from the original message + const pTags = messageToReplyTo.getMatchingTags("p"); + pTags.forEach(tag => { + if (tag[1]) { + recipientPubkeys.add(tag[1]); + } + }); + + // Remove the current user from recipients (don't reply to yourself) + const currentUserPubkey = $userStore.pubkey; + if (currentUserPubkey) { + recipientPubkeys.delete(currentUserPubkey); + } + + // Build the recipient list with profile information + selectedRecipients = Array.from(recipientPubkeys).map(pubkey => { + const profile = authorProfiles.get(pubkey); + return { + pubkey: pubkey, + name: profile?.name || "", + displayName: profile?.displayName || "", + picture: profile?.picture || "", + about: "", // We don't store about in authorProfiles + nip05: "", // We don't store nip05 in authorProfiles + }; + }).filter(recipient => recipient.pubkey); // Ensure we have valid pubkeys + + console.log(`Pre-loaded ${selectedRecipients.length} recipients for reply:`, selectedRecipients.map(r => r.displayName || r.name || r.pubkey?.slice(0, 8))); + } else { + quotedContent = ""; + } + } + + function closeNewMessageModal() { + showNewMessageModal = false; + newMessageContent = ""; + selectedRecipients = []; + newMessageRelays = []; + isComposingMessage = false; + replyToMessage = null; + quotedContent = ""; + } + + // AI-NOTE: Recipient Selection Modal Functions + function openRecipientModal() { + showRecipientModal = true; + recipientSearch = ""; + recipientResults = []; + recipientLoading = false; + recipientCommunityStatus = {}; + isRecipientSearching = false; + // Focus the search input after a brief delay to ensure modal is rendered + setTimeout(() => { + recipientSearchInput?.focus(); + }, 100); } - function cancelReply() { - replyingTo = null; - isReplying = false; - replyContent = ""; - replyingToMessageId = null; - replyRelays = []; - senderOutboxRelays = []; - recipientInboxRelays = []; + function closeRecipientModal() { + showRecipientModal = false; + recipientSearch = ""; + recipientResults = []; + recipientLoading = false; + recipientCommunityStatus = {}; + isRecipientSearching = false; + + // Clear any pending search timeout + if (recipientSearchTimeout) { + clearTimeout(recipientSearchTimeout); + recipientSearchTimeout = null; + } } - async function sendReply() { - if (!replyingTo || !replyContent.trim()) return; + async function searchRecipients() { + if (!recipientSearch.trim()) { + recipientResults = []; + recipientCommunityStatus = {}; + return; + } + + // Prevent multiple concurrent searches + if (isRecipientSearching) { + return; + } + + console.log("Starting recipient search for:", recipientSearch.trim()); + + // Set loading state + recipientLoading = true; + isRecipientSearching = true; try { - // Find the original message being replied to - const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId); - const result = await createKind24Reply(replyContent, replyingTo, originalMessage); - - if (result.success) { - // Store relay information for display - replyRelays = result.relays || []; - - // Update the inbox/outbox arrays to match the actual relays being used - // Keep only the top 3 that are actually in the reply relay set - const replyRelaySet = new Set(replyRelays); - senderOutboxRelays = senderOutboxRelays - .filter(relay => replyRelaySet.has(relay)) - .slice(0, 3); - recipientInboxRelays = recipientInboxRelays - .filter(relay => replyRelaySet.has(relay)) - .slice(0, 3); - - // Clear reply state - replyingTo = null; - isReplying = false; - replyContent = ""; - replyingToMessageId = null; - // Optionally refresh messages - await fetchPublicMessages(); - } else { - console.error("Failed to send reply:", result.error); - // You could show an error message to the user here - } + console.log("Recipient search promise created, waiting for result..."); + const result = await searchProfiles(recipientSearch.trim()); + console.log("Recipient search completed, found profiles:", result.profiles.length); + console.log("Profile details:", result.profiles); + console.log("Community status:", result.Status); + + // Update state + recipientResults = result.profiles; + recipientCommunityStatus = result.Status; + + console.log( + "State updated - recipientResults length:", + recipientResults.length, + ); + console.log( + "State updated - recipientCommunityStatus keys:", + Object.keys(recipientCommunityStatus), + ); } catch (error) { - console.error("Error sending reply:", error); + console.error("Error searching recipients:", error); + recipientResults = []; + recipientCommunityStatus = {}; + } finally { + recipientLoading = false; + isRecipientSearching = false; + console.log( + "Recipient search finished - loading:", + recipientLoading, + "searching:", + isRecipientSearching, + ); + } + } + + // Reactive search with debouncing + $effect(() => { + // Clear existing timeout + if (recipientSearchTimeout) { + clearTimeout(recipientSearchTimeout); + } + + // If search is empty, clear results immediately + if (!recipientSearch.trim()) { + recipientResults = []; + recipientCommunityStatus = {}; + recipientLoading = false; + return; + } + + // Set loading state immediately for better UX + recipientLoading = true; + + // Debounce the search with 300ms delay + recipientSearchTimeout = setTimeout(() => { + searchRecipients(); + }, 300); + }); + + function selectRecipient(profile: NostrProfile) { + // Check if recipient is already selected + if (selectedRecipients.some(r => r.pubkey === profile.pubkey)) { + console.log("Recipient already selected:", profile.displayName || profile.name); + return; } + + // Add recipient to selection + selectedRecipients = [...selectedRecipients, profile]; + console.log("Selected recipient:", profile.displayName || profile.name); + + // Close the recipient modal (New Message modal stays open) + closeRecipientModal(); } - // Function to get relay information before sending - async function getReplyRelays() { - if (!replyingTo) return; + async function sendNewMessage() { + if (!newMessageContent.trim() || selectedRecipients.length === 0) return; try { - const originalMessage = publicMessages.find(msg => msg.id === replyingToMessageId); + isComposingMessage = true; - // Get sender's outbox relays and recipient's inbox relays - const ndk = get(ndkInstance); - if (ndk?.activeUser) { - // Get sender's outbox relays - const senderUser = ndk.activeUser; - const senderRelayList = await ndk.fetchEvent({ - kinds: [10002], - authors: [senderUser.pubkey], - }); - - if (senderRelayList) { - senderOutboxRelays = senderRelayList.tags - .filter(tag => tag[0] === 'r' && tag[1]) - .map(tag => tag[1]) - .slice(0, 3); // Limit to top 3 outbox relays - } - - // Get recipient's inbox relays - const recipientUser = ndk.getUser({ pubkey: replyingTo }); - const recipientRelayList = await ndk.fetchEvent({ - kinds: [10002], - authors: [replyingTo], - }); - - if (recipientRelayList) { - recipientInboxRelays = recipientRelayList.tags - .filter(tag => tag[0] === 'r' && tag[1]) - .map(tag => tag[1]) - .slice(0, 3); // Limit to top 3 inbox relays - } + // Create p-tags for all recipients + const pTags = selectedRecipients.map(recipient => ["p", recipient.pubkey!]); + + // Get all recipient pubkeys for relay calculation + const recipientPubkeys = selectedRecipients.map(r => r.pubkey!); + + // Calculate relay set using the same logic as kind24_utils + const senderPubkey = $userStore.pubkey; + if (!senderPubkey) { + throw new Error("No sender pubkey available"); } - // If we have content, use the actual reply function - if (replyContent.trim()) { - const result = await createKind24Reply(replyContent, replyingTo, originalMessage); - replyRelays = result.relays || []; + // Get relay sets for all recipients and combine them + const relaySetPromises = recipientPubkeys.map(recipientPubkey => + getKind24RelaySet(senderPubkey, recipientPubkey) + ); + const relaySets = await Promise.all(relaySetPromises); + + // Combine and deduplicate all relay sets + const allRelays = relaySets.flat(); + const uniqueRelays = [...new Set(allRelays)]; + newMessageRelays = uniqueRelays; + + // Create the kind 24 event with quoted content if replying + let finalContent = newMessageContent; + if (replyToMessage && quotedContent) { + // Generate the markdown quote format for the actual message + const neventUrl = getNeventUrl(replyToMessage); + const markdownQuote = `> QUOTED: ${quotedContent} • LINK: ${neventUrl}`; + finalContent = markdownQuote + "\n\n" + newMessageContent; + } + + const eventData = { + kind: 24, + content: finalContent, + tags: pTags, + pubkey: $userStore.pubkey || '', + created_at: Math.floor(Date.now() / 1000) + }; + + // Sign the event + let signedEvent; + if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { + signedEvent = await window.nostr.signEvent(eventData); } else { - // If no content yet, just get the relay set for this recipient - const result = await getKind24RelaySet($userStore.pubkey || '', replyingTo); - replyRelays = result || []; - - // Update the inbox/outbox arrays to match the actual relays being used - // Keep only the top 3 that are actually in the reply relay set - const replyRelaySet = new Set(replyRelays); - senderOutboxRelays = senderOutboxRelays - .filter(relay => replyRelaySet.has(relay)) - .slice(0, 3); - recipientInboxRelays = recipientInboxRelays - .filter(relay => replyRelaySet.has(relay)) - .slice(0, 3); - + throw new Error("No signing method available"); + } + // Publish to relays using WebSocket pool like other components + const { WebSocketPool } = await import("$lib/data_structures/websocket_pool"); + let publishedToAny = false; + + for (const relayUrl of newMessageRelays) { + try { + const ws = await WebSocketPool.instance.acquire(relayUrl); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + WebSocketPool.instance.release(ws); + reject(new Error("Timeout")); + }, 5000); + + ws.onmessage = (e) => { + const [type, id, ok, message] = JSON.parse(e.data); + if (type === "OK" && id === signedEvent.id) { + clearTimeout(timeout); + if (ok) { + publishedToAny = true; + WebSocketPool.instance.release(ws); + resolve(); + } else { + WebSocketPool.instance.release(ws); + reject(new Error(message)); + } + } + }; + + ws.send(JSON.stringify(["EVENT", signedEvent])); + }); + } catch (e) { + console.warn(`Failed to publish to ${relayUrl}:`, e); + } + } + + if (publishedToAny) { + // Close modal and refresh messages + closeNewMessageModal(); + await fetchPublicMessages(); + } else { + throw new Error("Failed to publish to any relay"); } } catch (error) { - console.error("Error getting relay information:", error); - replyRelays = []; - senderOutboxRelays = []; - recipientInboxRelays = []; + console.error("Error sending new message:", error); + // You could show an error message to the user here + } finally { + isComposingMessage = false; } } + + // AI-NOTE: Simplified profile fetching with better error handling async function fetchAuthorProfiles(events: NDKEvent[]) { const uniquePubkeys = new Set(); @@ -545,10 +721,30 @@ } }); - // Fetch relay information when reply content changes (for updates) + + + // Calculate relay set when recipients change $effect(() => { - if (isReplying && replyingTo && replyContent.trim() && replyRelays.length === 0) { - getReplyRelays(); + const senderPubkey = $userStore.pubkey; + if (selectedRecipients.length > 0 && senderPubkey) { + const recipientPubkeys = selectedRecipients.map(r => r.pubkey!); + + // Get relay sets for all recipients and combine them + const relaySetPromises = recipientPubkeys.map(recipientPubkey => + getKind24RelaySet(senderPubkey, recipientPubkey) + ); + + Promise.all(relaySetPromises).then(relaySets => { + // Combine and deduplicate all relay sets + const allRelays = relaySets.flat(); + const uniqueRelays = [...new Set(allRelays)]; + newMessageRelays = uniqueRelays; + }).catch(error => { + console.error("Error getting relay set:", error); + newMessageRelays = []; + }); + } else { + newMessageRelays = []; } }); @@ -558,6 +754,18 @@
Notifications +
+ + +
{#each ["to-me", "from-me", "public-messages"] as mode} @@ -569,6 +777,7 @@ {modeLabel} {/each} +
@@ -634,12 +843,16 @@ @@ -697,63 +910,6 @@
- - {#if isReplying && replyingToMessageId === message.id} - {@const recipientProfile = authorProfiles.get(message.pubkey)} -
-
- - Replying to: {recipientProfile?.displayName || recipientProfile?.name || `${message.pubkey.slice(0, 8)}...${message.pubkey.slice(-4)}`} - - -
-
- - -
- - -
- {#if replyRelays.length > 0} - - - {:else} -
-
- Loading relay information... -
- {/if} -
-
- {/if}
{/each}
@@ -845,4 +1001,236 @@ {/if} {/if}
+ + + +
+
+

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

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

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

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

Select Recipients

+
+ +
+
+ + {#if recipientLoading} +
+
+
+ {/if} +
+ + {#if recipientResults.length > 0} +
+
    + {#each recipientResults as profile} + {@const isAlreadySelected = selectedRecipients.some(r => r.pubkey === profile.pubkey)} + + {/each} +
+
+ {:else if recipientSearch.trim()} +
No results found
+ {:else} +
+ Enter a search term to find users +
+ {/if} +
+
+
{/if} \ No newline at end of file diff --git a/src/lib/utils/profile_search.ts b/src/lib/utils/profile_search.ts index eeac332..ecf43ec 100644 --- a/src/lib/utils/profile_search.ts +++ b/src/lib/utils/profile_search.ts @@ -1,8 +1,8 @@ -import { ndkInstance } from "../ndk.ts"; +import { ndkInstance, activeInboxRelays } from "../ndk.ts"; import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts"; import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import { searchCache } from "./searchCache.ts"; -import { communityRelays, secondaryRelays } from "../consts.ts"; +import { searchRelays, communityRelays, secondaryRelays } from "../consts.ts"; import { get } from "svelte/store"; import type { NostrProfile, ProfileSearchResult } from "./search_types.ts"; import { @@ -264,12 +264,21 @@ async function quickRelaySearch( const normalizedSearchTerm = normalizeSearchTerm(searchTerm); console.log("Normalized search term for relay search:", normalizedSearchTerm); - // Use all profile relays for better coverage - const quickRelayUrls = [...communityRelays, ...secondaryRelays]; // Use all available relays - console.log("Using all relays for search:", quickRelayUrls); + // Use search relays (optimized for profiles) + user's inbox relays + community relays + const userInboxRelays = get(activeInboxRelays); + const quickRelayUrls = [ + ...searchRelays, // Dedicated profile search relays + ...userInboxRelays, // User's personal inbox relays + ...communityRelays, // Community relays + ...secondaryRelays // Secondary relays as fallback + ]; + + // Deduplicate relay URLs + const uniqueRelayUrls = [...new Set(quickRelayUrls)]; + console.log("Using relays for profile search:", uniqueRelayUrls); // Create relay sets for parallel search - const relaySets = quickRelayUrls + const relaySets = uniqueRelayUrls .map((url) => { try { return NDKRelaySet.fromRelayUrls([url], ndk); @@ -289,7 +298,7 @@ async function quickRelaySearch( let eventCount = 0; console.log( - `Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`, + `Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`, ); const sub = ndk.subscribe( @@ -354,7 +363,7 @@ async function quickRelaySearch( sub.on("eose", () => { console.log( - `Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`, + `Relay ${index + 1} (${uniqueRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`, ); resolve(foundInRelay); }); @@ -362,7 +371,7 @@ async function quickRelaySearch( // Short timeout for quick search setTimeout(() => { console.log( - `Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`, + `Relay ${index + 1} (${uniqueRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`, ); sub.stop(); resolve(foundInRelay); From a10f62c1e1d4d2eac4821db19bbc40493b83d0d2 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 09:12:05 +0200 Subject: [PATCH 21/29] show all 100 messages --- src/lib/components/Notifications.svelte | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 45ee83e..548896b 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -17,7 +17,7 @@ import { Modal, Button } from "flowbite-svelte"; import { searchProfiles } from "$lib/utils/search_utility"; import type { NostrProfile } from "$lib/utils/search_types"; - import { PlusOutline } from "flowbite-svelte-icons"; + import { PlusOutline, ReplyOutline } from "flowbite-svelte-icons"; const { event } = $props<{ event: NDKEvent }>(); @@ -410,6 +410,13 @@ // Create p-tags for all recipients const pTags = selectedRecipients.map(recipient => ["p", recipient.pubkey!]); + // Add q tag if replying to a message (for jump-to functionality) + if (replyToMessage) { + // Get the first relay from newMessageRelays or use a fallback + const relayUrl = newMessageRelays[0] || "wss://freelay.sovbit.host/"; + pTags.push(["q", replyToMessage.id, relayUrl, replyToMessage.pubkey]); + } + // Get all recipient pubkeys for relay calculation const recipientPubkeys = selectedRecipients.map(r => r.pubkey!); @@ -851,9 +858,7 @@ title="Reply to this message" aria-label="Reply to this message" > - - - +
From 493c08daeeb9f7d7c8cc7eb7f98c1e8a75d4d5d3 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 09:51:07 +0200 Subject: [PATCH 22/29] fixed jump quotes --- src/lib/components/Notifications.svelte | 55 ++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 548896b..136917e 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -128,6 +128,59 @@ return content.slice(0, maxLength) + "..."; } + function truncateRenderedContent(renderedHtml: string, maxLength: number = 300): string { + // If the rendered HTML is short enough, return as-is + if (renderedHtml.length <= maxLength) return renderedHtml; + + // Check if there are any gray quote boxes (jump-to-message divs) + const hasQuoteBoxes = renderedHtml.includes('jump-to-message'); + + if (hasQuoteBoxes) { + // Split content into quote boxes and regular text + const quoteBoxPattern = /
]*>[^<]*<\/div>/g; + const quoteBoxes = renderedHtml.match(quoteBoxPattern) || []; + + // Remove quote boxes temporarily to measure text length + let textOnly = renderedHtml.replace(quoteBoxPattern, '|||QUOTEBOX|||'); + + // If text without quote boxes is still too long, truncate it + if (textOnly.length > maxLength) { + const availableLength = maxLength - (quoteBoxes.join('').length); + if (availableLength > 50) { // Leave some reasonable space for text + textOnly = textOnly.slice(0, availableLength) + "..."; + } else { + // If quote boxes take up too much space, just show them with minimal text + textOnly = textOnly.slice(0, 50) + "..."; + } + } + + // Restore quote boxes + let result = textOnly; + quoteBoxes.forEach(box => { + result = result.replace('|||QUOTEBOX|||', box); + }); + + return result; + } else { + // No quote boxes, simple truncation with HTML awareness + if (renderedHtml.includes('<')) { + // Has HTML tags, do a simple truncation but try to avoid breaking tags + const truncated = renderedHtml.slice(0, maxLength); + const lastTagStart = truncated.lastIndexOf('<'); + const lastTagEnd = truncated.lastIndexOf('>'); + + if (lastTagStart > lastTagEnd) { + // We're in the middle of a tag, truncate before it + return renderedHtml.slice(0, lastTagStart) + "..."; + } + return truncated + "..."; + } else { + // Plain text, simple truncation + return renderedHtml.slice(0, maxLength) + "..."; + } + } + } + function renderContentWithLinks(content: string): string { // Parse markdown links [text](url) and convert to HTML let rendered = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); @@ -907,7 +960,7 @@ {#if message.content}
- {@html renderContentWithLinks(truncateContent(message.content))} + {@html truncateRenderedContent(renderContentWithLinks(message.content), 300)}
{/if} From e8ce9bb8eb5289d849fdc92b419630ea31453423 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 10:25:24 +0200 Subject: [PATCH 23/29] fix inbox/outbox selection --- src/lib/components/Notifications.svelte | 74 ++++++++++++++++++++++--- src/lib/utils/kind24_utils.ts | 54 +++++++++++++----- 2 files changed, 107 insertions(+), 21 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 136917e..8c6b992 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -460,8 +460,22 @@ try { isComposingMessage = true; - // Create p-tags for all recipients - const pTags = selectedRecipients.map(recipient => ["p", recipient.pubkey!]); + // Create p-tags for all recipients (ensure hex format) + const pTags = selectedRecipients.map(recipient => { + let pubkey = recipient.pubkey!; + // Convert npub to hex if needed + if (pubkey.startsWith('npub')) { + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkey = decoded.data; + } + } catch (e) { + console.warn("[Send Message] Failed to decode npub:", pubkey, e); + } + } + return ["p", pubkey]; + }); // Add q tag if replying to a message (for jump-to functionality) if (replyToMessage) { @@ -470,8 +484,22 @@ pTags.push(["q", replyToMessage.id, relayUrl, replyToMessage.pubkey]); } - // Get all recipient pubkeys for relay calculation - const recipientPubkeys = selectedRecipients.map(r => r.pubkey!); + // Get all recipient pubkeys for relay calculation (ensure hex format) + const recipientPubkeys = selectedRecipients.map(r => { + let pubkey = r.pubkey!; + // Convert npub to hex if needed + if (pubkey.startsWith('npub')) { + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkey = decoded.data; + } + } catch (e) { + console.warn("[Send Message Relay Calc] Failed to decode npub:", pubkey, e); + } + } + return pubkey; + }); // Calculate relay set using the same logic as kind24_utils const senderPubkey = $userStore.pubkey; @@ -786,8 +814,25 @@ // Calculate relay set when recipients change $effect(() => { const senderPubkey = $userStore.pubkey; + console.log("[Relay Effect] Recipients changed:", selectedRecipients.length, "Sender:", senderPubkey?.slice(0, 8)); + if (selectedRecipients.length > 0 && senderPubkey) { - const recipientPubkeys = selectedRecipients.map(r => r.pubkey!); + const recipientPubkeys = selectedRecipients.map(r => { + const pubkey = r.pubkey!; + // Convert npub to hex if needed + if (pubkey.startsWith('npub')) { + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + return decoded.data; + } + } catch (e) { + console.warn("[Relay Effect] Failed to decode npub:", pubkey, e); + } + } + return pubkey; + }); + console.log("[Relay Effect] Getting relay sets for recipients (hex):", recipientPubkeys.map(p => p.slice(0, 8))); // Get relay sets for all recipients and combine them const relaySetPromises = recipientPubkeys.map(recipientPubkey => @@ -795,15 +840,28 @@ ); Promise.all(relaySetPromises).then(relaySets => { + console.log("[Relay Effect] Received relay sets:", relaySets); // Combine and deduplicate all relay sets const allRelays = relaySets.flat(); const uniqueRelays = [...new Set(allRelays)]; - newMessageRelays = uniqueRelays; + console.log("[Relay Effect] Final relay list:", uniqueRelays); + + // If no relays found from NIP-65, use fallback relays + if (uniqueRelays.length === 0) { + console.log("[Relay Effect] No NIP-65 relays found, using fallback"); + const fallbackRelays = getAvailableRelays(); + newMessageRelays = fallbackRelays.slice(0, 5); // Limit to first 5 for performance + } else { + newMessageRelays = uniqueRelays; + } }).catch(error => { - console.error("Error getting relay set:", error); - newMessageRelays = []; + console.error("[Relay Effect] Error getting relay set:", error); + console.log("[Relay Effect] Using fallback relays due to error"); + const fallbackRelays = getAvailableRelays(); + newMessageRelays = fallbackRelays.slice(0, 5); }); } else { + console.log("[Relay Effect] Clearing relays - no recipients or sender"); newMessageRelays = []; } }); diff --git a/src/lib/utils/kind24_utils.ts b/src/lib/utils/kind24_utils.ts index edf362f..9d1271e 100644 --- a/src/lib/utils/kind24_utils.ts +++ b/src/lib/utils/kind24_utils.ts @@ -31,12 +31,11 @@ async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise { if (tag[0] === 'r' && tag[1]) { // NIP-65: r tags with optional inbox/outbox markers const marker = tag[2]; - if (!marker || marker === 'outbox' || marker === 'inbox') { - // If no marker or marker is 'outbox', it's a outbox relay - // If marker is 'inbox', it's also a outbox relay (NIP-65 allows both) + if (!marker || marker === 'outbox' || marker === 'both') { + // If no marker, marker is 'outbox', or marker is 'both', it's an outbox relay outboxRelays.push(tag[1]); - } + // Note: inbox-only relays are NOT included in outbox relays } }); @@ -56,6 +55,7 @@ async function getUseroutboxRelays(ndk: NDK, user: NDKUser): Promise { */ async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise { try { + console.log(`[getUserinboxRelays] Fetching kind 10002 for user: ${user.pubkey.slice(0, 8)}`); const relayList = await ndk.fetchEvent( { @@ -65,27 +65,31 @@ async function getUserinboxRelays(ndk: NDK, user: NDKUser): Promise { ); if (!relayList) { + console.log(`[getUserinboxRelays] No kind 10002 relay list found for user: ${user.pubkey.slice(0, 8)}`); return []; } + console.log(`[getUserinboxRelays] Found relay list for user: ${user.pubkey.slice(0, 8)}, tags:`, relayList.tags); + const inboxRelays: string[] = []; relayList.tags.forEach((tag) => { if (tag[0] === 'r' && tag[1]) { // NIP-65: r tags with optional inbox/outbox markers const marker = tag[2]; - if (!marker || marker === 'inbox' || marker === 'outbox') { - // If no marker or marker is 'inbox', it's a inbox relay - // If marker is 'outbox', it's also a inbox relay (NIP-65 allows both) + console.log(`[getUserinboxRelays] Processing relay tag:`, tag, `marker: ${marker}`); + if (!marker || marker === 'inbox' || marker === 'both') { + // If no marker, marker is 'inbox', or marker is 'both', it's an inbox relay inboxRelays.push(tag[1]); - + console.log(`[getUserinboxRelays] Added inbox relay: ${tag[1]} (marker: ${marker || 'none'})`); } + // Note: outbox-only relays are NOT included in inbox relays } }); - + console.log(`[getUserinboxRelays] Final inbox relays for user ${user.pubkey.slice(0, 8)}:`, inboxRelays); return inboxRelays; } catch (error) { - + console.error(`[getUserinboxRelays] Error fetching inbox relays for user ${user.pubkey.slice(0, 8)}:`, error); return []; } } @@ -117,7 +121,13 @@ export async function createKind24Reply( // Get recipient's inbox relays (NIP-65) const recipientUser = ndk.getUser({ pubkey: recipientPubkey }); - const recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser); + let recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser); + + // Fallback: if no inbox relays found, use recipient's outbox relays + if (recipientinboxRelays.length === 0) { + console.log(`[createKind24Reply] No inbox relays found for recipient, falling back to outbox relays`); + recipientinboxRelays = await getUseroutboxRelays(ndk, recipientUser); + } // According to NIP-A4: Messages MUST be sent to the NIP-65 inbox relays of each receiver // and the outbox relay of the sender @@ -212,17 +222,29 @@ export async function getKind24RelaySet( throw new Error("NDK not available"); } + console.log(`[getKind24RelaySet] Getting relays for sender: ${senderPubkey.slice(0, 8)} -> recipient: ${recipientPubkey.slice(0, 8)}`); + // Get sender's outbox relays (NIP-65) const senderUser = ndk.getUser({ pubkey: senderPubkey }); const senderoutboxRelays = await getUseroutboxRelays(ndk, senderUser); + console.log(`[getKind24RelaySet] Sender outbox relays:`, senderoutboxRelays); // Get recipient's inbox relays (NIP-65) const recipientUser = ndk.getUser({ pubkey: recipientPubkey }); - const recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser); + let recipientinboxRelays = await getUserinboxRelays(ndk, recipientUser); + console.log(`[getKind24RelaySet] Recipient inbox relays:`, recipientinboxRelays); + + // Fallback: if no inbox relays found, use recipient's outbox relays + if (recipientinboxRelays.length === 0) { + console.log(`[getKind24RelaySet] No inbox relays found for recipient, falling back to outbox relays`); + recipientinboxRelays = await getUseroutboxRelays(ndk, recipientUser); + console.log(`[getKind24RelaySet] Recipient outbox relays (used as fallback):`, recipientinboxRelays); + } // According to NIP-A4: Messages MUST be sent to the NIP-65 inbox relays of each receiver // and the outbox relay of the sender const targetRelays = [...new Set([...senderoutboxRelays, ...recipientinboxRelays])]; + console.log(`[getKind24RelaySet] Combined target relays:`, targetRelays); // Prioritize common relays between sender and recipient for better privacy const commonRelays = senderoutboxRelays.filter((relay: string) => @@ -235,6 +257,12 @@ export async function getKind24RelaySet( !senderoutboxRelays.includes(relay) ); + console.log(`[getKind24RelaySet] Common relays:`, commonRelays); + console.log(`[getKind24RelaySet] Sender-only relays:`, senderOnlyRelays); + console.log(`[getKind24RelaySet] Recipient-only relays:`, recipientOnlyRelays); + // Prioritize: common relays first, then sender outbox, then recipient inbox - return [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays]; + const finalRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays]; + console.log(`[getKind24RelaySet] Final relay list:`, finalRelays); + return finalRelays; } From d31b8bfc13e3333aa5c370e567e35208c5bb8256 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 10:29:15 +0200 Subject: [PATCH 24/29] got rid of noise --- src/lib/utils.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 18fad03..60237f8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -20,13 +20,6 @@ export class InvalidKindError extends DecodeError { export function neventEncode(event: NDKEvent, relays: string[]) { try { - console.log(`[neventEncode] Encoding event:`, { - id: event.id, - kind: event.kind, - pubkey: event.pubkey, - relayCount: relays.length - }); - const nevent = nip19.neventEncode({ id: event.id, kind: event.kind, @@ -34,7 +27,6 @@ export function neventEncode(event: NDKEvent, relays: string[]) { author: event.pubkey, }); - console.log(`[neventEncode] Generated nevent:`, nevent); return nevent; } catch (error) { console.error(`[neventEncode] Error encoding nevent:`, error); From e39febc25fc8dd811b50b1139f080e4c9e842201 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sat, 9 Aug 2025 10:37:55 +0200 Subject: [PATCH 25/29] reinstate ephemeral tag --- src/lib/components/Notifications.svelte | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 8c6b992..fa8e8f4 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -12,6 +12,7 @@ import { nip19 } from "nostr-tools"; import { communityRelays, localRelays } from "$lib/consts"; import { createKind24Reply, getKind24RelaySet } from "$lib/utils/kind24_utils"; + import { createSignedEvent } from "$lib/utils/nostrEventService"; import RelayDisplay from "$lib/components/RelayDisplay.svelte"; import RelayInfoList from "$lib/components/RelayInfoList.svelte"; import { Modal, Button } from "flowbite-svelte"; @@ -527,21 +528,13 @@ finalContent = markdownQuote + "\n\n" + newMessageContent; } - const eventData = { - kind: 24, - content: finalContent, - tags: pTags, - pubkey: $userStore.pubkey || '', - created_at: Math.floor(Date.now() / 1000) - }; - - // Sign the event - let signedEvent; - if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { - signedEvent = await window.nostr.signEvent(eventData); - } else { - throw new Error("No signing method available"); - } + // Create and sign the event using the unified function (includes expiration tag) + const { event: signedEvent } = await createSignedEvent( + finalContent, + $userStore.pubkey || '', + 24, + pTags + ); // Publish to relays using WebSocket pool like other components const { WebSocketPool } = await import("$lib/data_structures/websocket_pool"); From 9ea1462bcb2b1e0af22cde65dee2665b0f81a461 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 10 Aug 2025 15:11:55 +0200 Subject: [PATCH 26/29] Fixed reactivity inefficiency --- src/lib/components/Notifications.svelte | 65 +++++++++++++++---------- src/routes/+layout.svelte | 16 +++--- src/routes/events/+page.svelte | 16 +++--- 3 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index fa8e8f4..f46607a 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -804,18 +804,32 @@ - // Calculate relay set when recipients change + // AI-NOTE: Refactored to avoid blocking $effect with async operations + // Calculate relay set when recipients change - non-blocking approach $effect(() => { const senderPubkey = $userStore.pubkey; console.log("[Relay Effect] Recipients changed:", selectedRecipients.length, "Sender:", senderPubkey?.slice(0, 8)); if (selectedRecipients.length > 0 && senderPubkey) { - const recipientPubkeys = selectedRecipients.map(r => { + // Start async relay set calculation without blocking the effect + updateRelaySet(selectedRecipients, senderPubkey); + } else { + console.log("[Relay Effect] Clearing relays - no recipients or sender"); + newMessageRelays = []; + } + }); + + /** + * Updates relay set asynchronously to avoid blocking the reactive system + */ + async function updateRelaySet(recipients: any[], senderPubkey: string) { + try { + const recipientPubkeys = recipients.map(r => { const pubkey = r.pubkey!; // Convert npub to hex if needed if (pubkey.startsWith('npub')) { try { - const decoded = nip19.decode(pubkey); + const decoded = nip19.decode(pubkey) as unknown as { type: string; data: string }; if (decoded.type === 'npub') { return decoded.data; } @@ -832,32 +846,29 @@ getKind24RelaySet(senderPubkey, recipientPubkey) ); - Promise.all(relaySetPromises).then(relaySets => { - console.log("[Relay Effect] Received relay sets:", relaySets); - // Combine and deduplicate all relay sets - const allRelays = relaySets.flat(); - const uniqueRelays = [...new Set(allRelays)]; - console.log("[Relay Effect] Final relay list:", uniqueRelays); - - // If no relays found from NIP-65, use fallback relays - if (uniqueRelays.length === 0) { - console.log("[Relay Effect] No NIP-65 relays found, using fallback"); - const fallbackRelays = getAvailableRelays(); - newMessageRelays = fallbackRelays.slice(0, 5); // Limit to first 5 for performance - } else { - newMessageRelays = uniqueRelays; - } - }).catch(error => { - console.error("[Relay Effect] Error getting relay set:", error); - console.log("[Relay Effect] Using fallback relays due to error"); + const relaySets = await Promise.all(relaySetPromises); + console.log("[Relay Effect] Received relay sets:", relaySets); + + // Combine and deduplicate all relay sets + const allRelays = relaySets.flat(); + const uniqueRelays = [...new Set(allRelays)]; + console.log("[Relay Effect] Final relay list:", uniqueRelays); + + // If no relays found from NIP-65, use fallback relays + if (uniqueRelays.length === 0) { + console.log("[Relay Effect] No NIP-65 relays found, using fallback"); const fallbackRelays = getAvailableRelays(); - newMessageRelays = fallbackRelays.slice(0, 5); - }); - } else { - console.log("[Relay Effect] Clearing relays - no recipients or sender"); - newMessageRelays = []; + newMessageRelays = fallbackRelays.slice(0, 5); // Limit to first 5 for performance + } else { + newMessageRelays = uniqueRelays; + } + } catch (error) { + console.error("[Relay Effect] Error getting relay set:", error); + console.log("[Relay Effect] Using fallback relays due to error"); + const fallbackRelays = getAvailableRelays(); + newMessageRelays = fallbackRelays.slice(0, 5); } - }); + } {#if isOwnProfile && $userStore.signedIn} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1ae83af..3aae73f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -19,17 +19,21 @@ let summary = "Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages."; - // Reactive effect to log relay configuration when stores change - $effect(() => { + // AI-NOTE: Refactored to avoid blocking $effect with logging operations + // Reactive effect to log relay configuration when stores change - non-blocking approach + $effect.pre(() => { const inboxRelays = $activeInboxRelays; const outboxRelays = $activeOutboxRelays; // Only log if we have relays (not empty arrays) if (inboxRelays.length > 0 || outboxRelays.length > 0) { - console.log('🔌 Relay Configuration Updated:'); - console.log('📥 Inbox Relays:', inboxRelays); - console.log('📤 Outbox Relays:', outboxRelays); - console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); + // Defer logging to avoid blocking the reactive system + requestAnimationFrame(() => { + console.log('🔌 Relay Configuration Updated:'); + console.log('📥 Inbox Relays:', inboxRelays); + console.log('📤 Outbox Relays:', outboxRelays); + console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); + }); } }); diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte index f37d5a8..fc86dc5 100644 --- a/src/routes/events/+page.svelte +++ b/src/routes/events/+page.svelte @@ -392,17 +392,21 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; - // Reactive effect to log relay configuration when stores change - $effect(() => { + // AI-NOTE: Refactored to avoid blocking $effect with logging operations + // Reactive effect to log relay configuration when stores change - non-blocking approach + $effect.pre(() => { const inboxRelays = $activeInboxRelays; const outboxRelays = $activeOutboxRelays; // Only log if we have relays (not empty arrays) if (inboxRelays.length > 0 || outboxRelays.length > 0) { - console.log('🔌 Events Page - Relay Configuration Updated:'); - console.log('📥 Inbox Relays:', inboxRelays); - console.log('📤 Outbox Relays:', outboxRelays); - console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); + // Defer logging to avoid blocking the reactive system + requestAnimationFrame(() => { + console.log('🔌 Events Page - Relay Configuration Updated:'); + console.log('📥 Inbox Relays:', inboxRelays); + console.log('📤 Outbox Relays:', outboxRelays); + console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); + }); } }); From 176e710dff435df3b56b42e97878ce44a307e7d5 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 10 Aug 2025 15:49:56 +0200 Subject: [PATCH 27/29] simplified quote structure and corrected msg jump --- src/lib/components/Notifications.svelte | 139 ++++++++++++++++-------- src/lib/utils/kind24_utils.ts | 14 +-- 2 files changed, 98 insertions(+), 55 deletions(-) diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index f46607a..e915315 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -19,6 +19,7 @@ import { searchProfiles } from "$lib/utils/search_utility"; import type { NostrProfile } from "$lib/utils/search_types"; import { PlusOutline, ReplyOutline } from "flowbite-svelte-icons"; + import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; const { event } = $props<{ event: NDKEvent }>(); @@ -182,31 +183,46 @@ } } - function renderContentWithLinks(content: string): string { - // Parse markdown links [text](url) and convert to HTML - let rendered = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + async function parseContent(content: string): Promise { + if (!content) return ""; - // Handle quote format and convert to small gray bars like Jumble - const patterns = [ - /> QUOTED: ([^•]*?) • LINK:\s*\n((?:nostr:)?nevent[^\s]*)/g, - /> QUOTED: ([^\n]*?)\n> LINK: ((?:nostr:)?nevent[^\s]*)/g, - /> QUOTED: ([^•]*?) • LINK:\s*((?:nostr:)?nevent[^\s]*)/g, - /> QUOTED: ([^•]*?) • LINK: ((?:nostr:)?nevent[^\s]*)/g, // Without optional whitespace - ]; + let parsedContent = await parseBasicmarkup(content); - for (const pattern of patterns) { - const beforeReplace = rendered; - rendered = rendered.replace(pattern, (match, quotedText, neventUrl) => { - const encodedUrl = neventUrl.replace(/'/g, '''); - const cleanQuotedText = quotedText.trim(); - return `
${cleanQuotedText}
`; - }); - if (beforeReplace !== rendered) { - break; + return parsedContent; + } + + function renderQuotedContent(message: NDKEvent): string { + const qTags = message.getMatchingTags("q"); + if (qTags.length === 0) return ""; + + const qTag = qTags[0]; + const nevent = qTag[1]; + + // Extract event ID from nevent + let eventId = ''; + try { + const decoded = nip19.decode(nevent); + if (decoded.type === 'nevent' && decoded.data.id) { + eventId = decoded.data.id; + } + } catch (error) { + // If decode fails, try to extract hex ID directly + const hexMatch = nevent.match(/[a-f0-9]{64}/i); + if (hexMatch) { + eventId = hexMatch[0]; + } + } + + if (eventId) { + // Find the quoted message in our public messages + const quotedMessage = publicMessages.find(msg => msg.id === eventId); + if (quotedMessage) { + const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"; + return `
${quotedContent}
`; } } - return rendered; + return ""; } function getNotificationType(event: NDKEvent): string { @@ -226,35 +242,59 @@ goto(`/events?id=${nevent}`); } - function jumpToMessageInFeed(nevent: string) { + function jumpToMessageInFeed(eventIdOrNevent: string) { // Switch to public messages tab and scroll to the specific message notificationMode = "public-messages"; // Try to find and scroll to the specific message setTimeout(() => { - try { - // Decode the nevent to get the event ID - const decoded = nip19.decode(nevent); - if (decoded.type === 'nevent' && decoded.data.id) { - const eventId = decoded.data.id; + let eventId = eventIdOrNevent; + + // If it's a nevent URL, try to extract the event ID + if (eventIdOrNevent.startsWith('nostr:nevent') || eventIdOrNevent.startsWith('nevent')) { + try { + const decoded = nip19.decode(eventIdOrNevent); + if (decoded.type === 'nevent' && decoded.data.id) { + eventId = decoded.data.id; + } + } catch (error) { + // If decode fails, try to extract hex ID directly + const hexMatch = eventIdOrNevent.match(/[a-f0-9]{64}/i); + if (hexMatch) { + eventId = hexMatch[0]; + } else { + console.warn('Failed to extract event ID from nevent:', eventIdOrNevent); + return; + } + } + } + + // Find the message in our public messages + const targetMessage = publicMessages.find(msg => msg.id === eventId); + if (targetMessage) { + // Try to find the element in the DOM + const element = document.querySelector(`[data-event-id="${eventId}"]`); + if (element) { + // Check if element is in viewport + const rect = element.getBoundingClientRect(); + const isInView = ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); - // Find the message in our public messages - const targetMessage = publicMessages.find(msg => msg.id === eventId); - if (targetMessage) { - // Try to scroll to the element if it exists in the DOM - const element = document.querySelector(`[data-event-id="${eventId}"]`); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - // Briefly highlight the message - element.classList.add('ring-2', 'ring-blue-500'); - setTimeout(() => { - element.classList.remove('ring-2', 'ring-blue-500'); - }, 2000); - } + // Only scroll if not in view + if (!isInView) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); } + + // ALWAYS highlight the message in blue + element.classList.add('ring-2', 'ring-blue-500'); + setTimeout(() => { + element.classList.remove('ring-2', 'ring-blue-500'); + }, 2000); } - } catch (error) { - console.warn('Failed to jump to message:', error); } }, 100); } @@ -1020,9 +1060,18 @@ {/if}
+ {#if message.getMatchingTags("q").length > 0} +
+ {@html renderQuotedContent(message)} +
+ {/if} {#if message.content}
- {@html truncateRenderedContent(renderContentWithLinks(message.content), 300)} + {#await parseContent(message.content) then parsedContent} + {@html parsedContent} + {:catch} + {@html message.content} + {/await}
{/if} @@ -1136,7 +1185,11 @@
Replying to:
- {@html renderContentWithLinks(quotedContent)} + {#await parseContent(quotedContent) then parsedContent} + {@html parsedContent} + {:catch} + {@html quotedContent} + {/await}
{/if} diff --git a/src/lib/utils/kind24_utils.ts b/src/lib/utils/kind24_utils.ts index 9d1271e..cb43e57 100644 --- a/src/lib/utils/kind24_utils.ts +++ b/src/lib/utils/kind24_utils.ts @@ -151,18 +151,8 @@ export async function createKind24Reply( return { success: false, error: "No relays available for publishing" }; } - // Build content with quoted message if replying - let finalContent = content; - if (originalEvent) { - // Use multiple relays for better discoverability - const nevent = nip19.neventEncode({ - id: originalEvent.id, - relays: prioritizedRelays.slice(0, 3) // Use first 3 relays - }); - const quotedContent = originalEvent.content ? originalEvent.content.slice(0, 200) : "No content"; - // Use a more visible quote format with a clickable link - finalContent = `> QUOTED: ${quotedContent}\n> LINK: ${nevent}\n\n${content}`; - } + // Use the content as-is, quoted content is handled via q tag + const finalContent = content; // Build tags for the kind 24 event const tags: string[][] = [ From 22235235b31cd97b322ba89e1cfe5e9f42877c54 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 10 Aug 2025 16:08:48 +0200 Subject: [PATCH 28/29] got rid of hard-coded relays --- src/lib/components/EventInput.svelte | 5 +-- src/lib/components/Notifications.svelte | 56 +++++++------------------ src/lib/consts.ts | 2 + src/lib/utils/kind24_utils.ts | 7 +--- src/routes/contact/+page.svelte | 10 ++--- 5 files changed, 24 insertions(+), 56 deletions(-) diff --git a/src/lib/components/EventInput.svelte b/src/lib/components/EventInput.svelte index 0519692..cec2cde 100644 --- a/src/lib/components/EventInput.svelte +++ b/src/lib/components/EventInput.svelte @@ -29,6 +29,7 @@ import { Button } from "flowbite-svelte"; import { goto } from "$app/navigation"; import { WebSocketPool } from "$lib/data_structures/websocket_pool"; + import { anonymousRelays } from "$lib/consts"; let kind = $state(30040); let tags = $state<[string, string][]>([]); @@ -384,9 +385,7 @@ // Try to publish to relays directly const relays = [ - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://nos.lol", + ...anonymousRelays, ...$activeOutboxRelays, ...$activeInboxRelays, ]; diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index e915315..4a98bc3 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -10,7 +10,7 @@ import { goto } from "$app/navigation"; import { get } from "svelte/store"; import { nip19 } from "nostr-tools"; - import { communityRelays, localRelays } from "$lib/consts"; + import { communityRelays, localRelays, anonymousRelays, searchRelays } from "$lib/consts"; import { createKind24Reply, getKind24RelaySet } from "$lib/utils/kind24_utils"; import { createSignedEvent } from "$lib/utils/nostrEventService"; import RelayDisplay from "$lib/components/RelayDisplay.svelte"; @@ -191,38 +191,24 @@ return parsedContent; } - function renderQuotedContent(message: NDKEvent): string { + async function renderQuotedContent(message: NDKEvent): Promise { const qTags = message.getMatchingTags("q"); if (qTags.length === 0) return ""; const qTag = qTags[0]; - const nevent = qTag[1]; - - // Extract event ID from nevent - let eventId = ''; - try { - const decoded = nip19.decode(nevent); - if (decoded.type === 'nevent' && decoded.data.id) { - eventId = decoded.data.id; - } - } catch (error) { - // If decode fails, try to extract hex ID directly - const hexMatch = nevent.match(/[a-f0-9]{64}/i); - if (hexMatch) { - eventId = hexMatch[0]; - } - } + const eventId = qTag[1]; if (eventId) { // Find the quoted message in our public messages const quotedMessage = publicMessages.find(msg => msg.id === eventId); if (quotedMessage) { const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"; - return `
${quotedContent}
`; + const parsedContent = await parseBasicmarkup(quotedContent); + return `
${parsedContent}
`; } } - return ""; + return ""; } function getNotificationType(event: NDKEvent): string { @@ -520,9 +506,7 @@ // Add q tag if replying to a message (for jump-to functionality) if (replyToMessage) { - // Get the first relay from newMessageRelays or use a fallback - const relayUrl = newMessageRelays[0] || "wss://freelay.sovbit.host/"; - pTags.push(["q", replyToMessage.id, relayUrl, replyToMessage.pubkey]); + pTags.push(["q", replyToMessage.id, newMessageRelays[0] || anonymousRelays[0]]); } // Get all recipient pubkeys for relay calculation (ensure hex format) @@ -559,14 +543,8 @@ const uniqueRelays = [...new Set(allRelays)]; newMessageRelays = uniqueRelays; - // Create the kind 24 event with quoted content if replying - let finalContent = newMessageContent; - if (replyToMessage && quotedContent) { - // Generate the markdown quote format for the actual message - const neventUrl = getNeventUrl(replyToMessage); - const markdownQuote = `> QUOTED: ${quotedContent} • LINK: ${neventUrl}`; - finalContent = markdownQuote + "\n\n" + newMessageContent; - } + // Use the content as-is, quoted content is handled via q tag + const finalContent = newMessageContent; // Create and sign the event using the unified function (includes expiration tag) const { event: signedEvent } = await createSignedEvent( @@ -649,16 +627,6 @@ } // Try search relays - const searchRelays = [ - "wss://profiles.nostr1.com", - "wss://aggr.nostr.land", - "wss://relay.noswhere.com", - "wss://nostr.wine", - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://freelay.sovbit.host" - ]; - for (const relay of searchRelays) { try { const ndk = get(ndkInstance); @@ -1062,7 +1030,11 @@ {#if message.getMatchingTags("q").length > 0}
- {@html renderQuotedContent(message)} + {#await renderQuotedContent(message) then quotedHtml} + {@html quotedHtml} + {:catch} + + {/await}
{/if} {#if message.content} diff --git a/src/lib/consts.ts b/src/lib/consts.ts index f141e7b..b8e7f0d 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -33,6 +33,8 @@ export const secondaryRelays = [ export const anonymousRelays = [ "wss://freelay.sovbit.host", "wss://thecitadel.nostr1.com", + "wss://relay.damus.io", + "wss://relay.nostr.band" ]; export const lowbandwidthRelays = [ diff --git a/src/lib/utils/kind24_utils.ts b/src/lib/utils/kind24_utils.ts index cb43e57..69c59ed 100644 --- a/src/lib/utils/kind24_utils.ts +++ b/src/lib/utils/kind24_utils.ts @@ -5,6 +5,7 @@ import { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk"; import { nip19 } from "nostr-tools"; import { createSignedEvent } from "./nostrEventService.ts"; +import { anonymousRelays } from "../consts"; /** * Fetches user's outbox relays from NIP-65 relay list @@ -161,11 +162,7 @@ export async function createKind24Reply( // Add q tag if replying to an original event if (originalEvent) { - const nevent = nip19.neventEncode({ - id: originalEvent.id, - relays: prioritizedRelays.slice(0, 3) // Use first 3 relays - }); - tags.push(["q", nevent, prioritizedRelays[0]]); + tags.push(["q", originalEvent.id, prioritizedRelays[0] || anonymousRelays[0]]); } // Create and sign the event using the unified function (includes expiration tag) diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index 4137220..b520ddc 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -11,7 +11,7 @@ } from "flowbite-svelte"; import { ndkInstance, ndkSignedIn, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { userStore } from "$lib/stores/userStore"; - import { communityRelays } from "$lib/consts"; + import { communityRelays, anonymousRelays } from "$lib/consts"; import type NDK from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk"; // @ts-ignore - Workaround for Svelte component import issue @@ -62,13 +62,11 @@ const repoAddress = "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr"; - // Use the new relay management system instead of hardcoded relays + // Use the new relay management system with anonymous relays as fallbacks const allRelays = [ - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://nos.lol", ...$activeInboxRelays, ...$activeOutboxRelays, + ...anonymousRelays, ]; // Hard-coded repository owner pubkey and ID from the task @@ -213,7 +211,7 @@ ...(ndk.pool ? Array.from(ndk.pool.relays.values()) .filter( - (relay) => relay.url && !relay.url.includes("wss://nos.lol"), + (relay) => relay.url, ) .map((relay) => normalizeRelayUrl(relay.url)) : []), From 8479a8c7dceecae02e0b00536258ad1801a4455b Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 10 Aug 2025 17:39:41 +0200 Subject: [PATCH 29/29] Redid the formatting. --- src/lib/components/Notifications.svelte | 397 ++++++------------------ src/lib/utils/kind24_utils.ts | 240 ++++---------- src/lib/utils/notification_utils.ts | 225 ++++++++++++++ 3 files changed, 386 insertions(+), 476 deletions(-) create mode 100644 src/lib/utils/notification_utils.ts diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 4a98bc3..f0995c7 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -5,8 +5,6 @@ import { userStore } from "$lib/stores/userStore"; import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte"; import { ndkInstance, activeInboxRelays } from "$lib/ndk"; - import { neventEncode } from "$lib/utils"; - import { getUserMetadata, NDKRelaySetFromNDK } from "$lib/utils/nostrUtils"; import { goto } from "$app/navigation"; import { get } from "svelte/store"; import { nip19 } from "nostr-tools"; @@ -19,7 +17,19 @@ import { searchProfiles } from "$lib/utils/search_utility"; import type { NostrProfile } from "$lib/utils/search_types"; import { PlusOutline, ReplyOutline } from "flowbite-svelte-icons"; + import { + truncateContent, + truncateRenderedContent, + parseContent, + renderQuotedContent, + getNotificationType, + fetchAuthorProfiles + } from "$lib/utils/notification_utils"; + import { buildCompleteRelaySet } from "$lib/utils/relay_management"; + import { formatDate, neventEncode } from "$lib/utils"; + import { toNpub, getUserMetadata, NDKRelaySetFromNDK } from "$lib/utils/nostrUtils"; import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; + import { userBadge } from "$lib/snippets/UserSnippets.svelte"; const { event } = $props<{ event: NDKEvent }>(); @@ -79,148 +89,9 @@ }); // AI-NOTE: Utility functions extracted to reduce code duplication - function getAvailableRelays(): string[] { - const userInboxRelays = $userStore.relays.inbox || []; - const userOutboxRelays = $userStore.relays.outbox || []; - const activeInboxRelayList = get(activeInboxRelays); - - const allRelays = [ - ...userInboxRelays, - ...userOutboxRelays, - ...localRelays, - ...communityRelays, - ...activeInboxRelayList - ]; - - return [...new Set(allRelays)]; - } - - function toNpub(pubkey: string): string | null { - if (!pubkey) return null; - try { - if (/^[a-f0-9]{64}$/i.test(pubkey)) { - return nip19.npubEncode(pubkey); - } - if (pubkey.startsWith("npub1")) return pubkey; - return null; - } catch { - return null; - } - } - function getNeventUrl(event: NDKEvent): string { - const relays = getAvailableRelays(); - return neventEncode(event, relays); - } - - function formatDate(timestamp: number): string { - const date = new Date(timestamp * 1000); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) return "Today"; - if (diffDays === 1) return "Yesterday"; - if (diffDays < 7) return `${diffDays} days ago`; - return date.toLocaleDateString(); - } - - function truncateContent(content: string, maxLength: number = 300): string { - if (content.length <= maxLength) return content; - return content.slice(0, maxLength) + "..."; - } - - function truncateRenderedContent(renderedHtml: string, maxLength: number = 300): string { - // If the rendered HTML is short enough, return as-is - if (renderedHtml.length <= maxLength) return renderedHtml; - - // Check if there are any gray quote boxes (jump-to-message divs) - const hasQuoteBoxes = renderedHtml.includes('jump-to-message'); - - if (hasQuoteBoxes) { - // Split content into quote boxes and regular text - const quoteBoxPattern = /
]*>[^<]*<\/div>/g; - const quoteBoxes = renderedHtml.match(quoteBoxPattern) || []; - - // Remove quote boxes temporarily to measure text length - let textOnly = renderedHtml.replace(quoteBoxPattern, '|||QUOTEBOX|||'); - - // If text without quote boxes is still too long, truncate it - if (textOnly.length > maxLength) { - const availableLength = maxLength - (quoteBoxes.join('').length); - if (availableLength > 50) { // Leave some reasonable space for text - textOnly = textOnly.slice(0, availableLength) + "..."; - } else { - // If quote boxes take up too much space, just show them with minimal text - textOnly = textOnly.slice(0, 50) + "..."; - } - } - - // Restore quote boxes - let result = textOnly; - quoteBoxes.forEach(box => { - result = result.replace('|||QUOTEBOX|||', box); - }); - - return result; - } else { - // No quote boxes, simple truncation with HTML awareness - if (renderedHtml.includes('<')) { - // Has HTML tags, do a simple truncation but try to avoid breaking tags - const truncated = renderedHtml.slice(0, maxLength); - const lastTagStart = truncated.lastIndexOf('<'); - const lastTagEnd = truncated.lastIndexOf('>'); - - if (lastTagStart > lastTagEnd) { - // We're in the middle of a tag, truncate before it - return renderedHtml.slice(0, lastTagStart) + "..."; - } - return truncated + "..."; - } else { - // Plain text, simple truncation - return renderedHtml.slice(0, maxLength) + "..."; - } - } - } - - async function parseContent(content: string): Promise { - if (!content) return ""; - - let parsedContent = await parseBasicmarkup(content); - - return parsedContent; - } - - async function renderQuotedContent(message: NDKEvent): Promise { - const qTags = message.getMatchingTags("q"); - if (qTags.length === 0) return ""; - - const qTag = qTags[0]; - const eventId = qTag[1]; - - if (eventId) { - // Find the quoted message in our public messages - const quotedMessage = publicMessages.find(msg => msg.id === eventId); - if (quotedMessage) { - const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"; - const parsedContent = await parseBasicmarkup(quotedContent); - return `
${parsedContent}
`; - } - } - - return ""; - } - - function getNotificationType(event: NDKEvent): string { - switch (event.kind) { - case 1: return "Reply"; - case 1111: return "Custom Reply"; - case 9802: return "Highlight"; - case 6: return "Repost"; - case 16: return "Generic Repost"; - case 24: return "Public Message"; - default: return `Kind ${event.kind}`; - } + // Use empty relay list for nevent encoding - relays will be discovered by the events page + return neventEncode(event, []); } function navigateToEvent(nevent: string) { @@ -605,88 +476,6 @@ } } - - - // AI-NOTE: Simplified profile fetching with better error handling - async function fetchAuthorProfiles(events: NDKEvent[]) { - const uniquePubkeys = new Set(); - events.forEach(event => { - if (event.pubkey) uniquePubkeys.add(event.pubkey); - }); - - const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => { - try { - const npub = toNpub(pubkey); - if (!npub) return; - - // Try cache first - let profile = await getUserMetadata(npub, false); - if (profile && (profile.name || profile.displayName || profile.picture)) { - authorProfiles.set(pubkey, profile); - return; - } - - // Try search relays - for (const relay of searchRelays) { - try { - const ndk = get(ndkInstance); - if (!ndk) break; - - const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk); - const profileEvent = await ndk.fetchEvent( - { kinds: [0], authors: [pubkey] }, - undefined, - relaySet - ); - - if (profileEvent) { - const profileData = JSON.parse(profileEvent.content); - authorProfiles.set(pubkey, { - name: profileData.name, - displayName: profileData.display_name || profileData.displayName, - picture: profileData.picture || profileData.image - }); - return; - } - } catch (error) { - console.warn(`[Notifications] Failed to fetch profile from ${relay}:`, error); - } - } - - // Try all available relays as fallback - const relays = getAvailableRelays(); - if (relays.length > 0) { - try { - const ndk = get(ndkInstance); - if (!ndk) return; - - const relaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); - const profileEvent = await ndk.fetchEvent( - { kinds: [0], authors: [pubkey] }, - undefined, - relaySet - ); - - if (profileEvent) { - const profileData = JSON.parse(profileEvent.content); - authorProfiles.set(pubkey, { - name: profileData.name, - displayName: profileData.display_name || profileData.displayName, - picture: profileData.picture || profileData.image - }); - } - } catch (error) { - console.warn(`[Notifications] Failed to fetch profile from all relays:`, error); - } - } - } catch (error) { - console.warn(`[Notifications] Failed to fetch profile for ${pubkey}:`, error); - } - }); - - await Promise.allSettled(profilePromises); - } - // AI-NOTE: Simplified notification fetching async function fetchNotifications() { if (!$userStore.pubkey || !isOwnProfile) return; @@ -697,8 +486,11 @@ try { const ndk = get(ndkInstance); if (!ndk) throw new Error("No NDK instance available"); - - const relays = getAvailableRelays(); + + const userStoreValue = get(userStore); + const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; + const relaySet = await buildCompleteRelaySet(ndk, user); + const relays = [...relaySet.inboxRelays, ...relaySet.outboxRelays]; if (relays.length === 0) throw new Error("No relays available"); const filter = { @@ -710,8 +502,8 @@ limit: 100, }; - const relaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); - const events = await ndk.fetchEvents(filter, undefined, relaySet); + const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); + const events = await ndk.fetchEvents(filter, undefined, ndkRelaySet); const eventArray = Array.from(events); // Filter out self-referential events @@ -729,7 +521,7 @@ .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .slice(0, 100); - await fetchAuthorProfiles(notifications); + authorProfiles = await fetchAuthorProfiles(notifications); } catch (err) { console.error("[Notifications] Error fetching notifications:", err); error = err instanceof Error ? err.message : "Failed to fetch notifications"; @@ -749,15 +541,18 @@ const ndk = get(ndkInstance); if (!ndk) throw new Error("No NDK instance available"); - const relays = getAvailableRelays(); + const userStoreValue = get(userStore); + const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; + const relaySet = await buildCompleteRelaySet(ndk, user); + const relays = [...relaySet.inboxRelays, ...relaySet.outboxRelays]; if (relays.length === 0) throw new Error("No relays available"); - const relaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); + const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(relays, ndk); // Fetch only kind 24 messages const [messagesEvents, userMessagesEvents] = await Promise.all([ - ndk.fetchEvents({ kinds: [24 as any], "#p": [$userStore.pubkey], limit: 200 }, undefined, relaySet), - ndk.fetchEvents({ kinds: [24 as any], authors: [$userStore.pubkey], limit: 200 }, undefined, relaySet) + 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) ]); const allMessages = [ @@ -774,7 +569,7 @@ .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) .slice(0, 200); - await fetchAuthorProfiles(publicMessages); + authorProfiles = await fetchAuthorProfiles(publicMessages); } catch (err) { console.error("[PublicMessages] Error fetching public messages:", err); error = err instanceof Error ? err.message : "Failed to fetch public messages"; @@ -865,16 +660,32 @@ // If no relays found from NIP-65, use fallback relays if (uniqueRelays.length === 0) { console.log("[Relay Effect] No NIP-65 relays found, using fallback"); - const fallbackRelays = getAvailableRelays(); - newMessageRelays = fallbackRelays.slice(0, 5); // Limit to first 5 for performance + const ndk = get(ndkInstance); + if (ndk) { + const userStoreValue = get(userStore); + const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; + const relaySet = await buildCompleteRelaySet(ndk, user); + const fallbackRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays]; + newMessageRelays = fallbackRelays.slice(0, 5); // Limit to first 5 for performance + } else { + newMessageRelays = []; + } } else { newMessageRelays = uniqueRelays; } } catch (error) { console.error("[Relay Effect] Error getting relay set:", error); console.log("[Relay Effect] Using fallback relays due to error"); - const fallbackRelays = getAvailableRelays(); - newMessageRelays = fallbackRelays.slice(0, 5); + const ndk = get(ndkInstance); + if (ndk) { + const userStoreValue = get(userStore); + const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null; + const relaySet = await buildCompleteRelaySet(ndk, user); + const fallbackRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays]; + newMessageRelays = fallbackRelays.slice(0, 5); + } else { + newMessageRelays = []; + } } } @@ -933,7 +744,7 @@
- Filtered by user: {authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name || `${filteredByUser.slice(0, 8)}...${filteredByUser.slice(-4)}`} + Filtered by user: {@render userBadge(filteredByUser, authorProfiles.get(filteredByUser)?.displayName || authorProfiles.get(filteredByUser)?.name)}
- -
- - {authorProfile?.displayName || authorProfile?.name || `${message.pubkey.slice(0, 8)}...${message.pubkey.slice(-4)}`} - - {#if authorProfile?.name && authorProfile?.displayName && authorProfile.name !== authorProfile.displayName} - - (@{authorProfile.name}) - - {/if} -
+ {#if message.getMatchingTags("q").length > 0}
- {#await renderQuotedContent(message) then quotedHtml} + {#await renderQuotedContent(message, publicMessages) then quotedHtml} {@html quotedHtml} {:catch} @@ -1073,22 +879,27 @@ {@const authorProfile = authorProfiles.get(notification.pubkey)}
- +
- {#if authorProfile?.picture} - Author avatar (e.target as HTMLImageElement).style.display = 'none'} - /> - {:else} -
- - {(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()} - -
- {/if} +
+ {#if authorProfile?.picture} + Author avatar (e.target as HTMLImageElement).style.display = 'none'} + /> + {:else} +
+ + {(authorProfile?.displayName || authorProfile?.name || notification.pubkey.slice(0, 1)).toUpperCase()} + +
+ {/if} + + {@render userBadge(notification.pubkey, authorProfile?.displayName || authorProfile?.name)} + +
@@ -1109,21 +920,15 @@
- -
- - {authorProfile?.displayName || authorProfile?.name || `${notification.pubkey.slice(0, 8)}...${notification.pubkey.slice(-4)}`} - - {#if authorProfile?.name && authorProfile?.displayName && authorProfile.name !== authorProfile.displayName} - - (@{authorProfile.name}) - - {/if} -
+ {#if notification.content}
- {truncateContent(notification.content)} + {#await parseContent(notification.content) then parsedContent} + {@html parsedContent} + {:catch} + {@html truncateContent(notification.content)} + {/await}
{/if} @@ -1194,7 +999,7 @@
{#each selectedRecipients as recipient} - {recipient.displayName || recipient.name || `${recipient.pubkey?.slice(0, 8)}...`} + {@render userBadge(recipient.pubkey!, recipient.displayName || recipient.name)}