From 1d0d078bb806f5138e4dca72fc0e7361dfd98327 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 10 Feb 2026 22:47:33 +0100 Subject: [PATCH] bug-fixes --- .../content/MarkdownRenderer.svelte | 119 ++++++- .../find/SearchAddressableEvents.svelte | 199 +++++++---- src/lib/modules/feed/FeedPost.svelte | 2 +- src/lib/modules/feed/HighlightCard.svelte | 16 +- .../reactions/FeedReactionButtons.svelte | 100 ++++++ src/routes/find/+page.svelte | 41 ++- src/routes/replaceable/[d_tag]/+page.svelte | 335 ------------------ 7 files changed, 398 insertions(+), 414 deletions(-) delete mode 100644 src/routes/replaceable/[d_tag]/+page.svelte diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index bffdb6f..7873884 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -34,6 +34,8 @@ let highlightsLoaded = $state(false); // Cache for rendered markdown to avoid re-rendering same content + // Version 2: Added wikilink conversion support + const MARKDOWN_CACHE_VERSION = 2; const markdownCache = new Map(); const MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory bloat @@ -401,11 +403,112 @@ return processedLines.join('\n'); } + // Normalize d-tag according to NIP-54 + function normalizeDTag(text: string): string { + let normalized = text; + + // Convert to lowercase (preserving non-ASCII characters) + normalized = normalized.toLowerCase(); + + // Convert whitespace to `-` + normalized = normalized.replace(/\s+/g, '-'); + + // Remove punctuation and symbols (but preserve non-ASCII letters and numbers) + normalized = normalized.replace(/[^\p{L}\p{N}-]/gu, ''); + + // Collapse multiple consecutive `-` to a single `-` + normalized = normalized.replace(/-+/g, '-'); + + // Remove leading and trailing `-` + normalized = normalized.replace(/^-+|-+$/g, ''); + + return normalized; + } + + // Convert wikilinks [[target]] or [[target|display]] to markdown links + // Skips wikilinks inside code blocks + function convertWikilinks(text: string): string { + // Find all code blocks (fenced and inline) to exclude from processing + const codeBlockRanges: Array<{ start: number; end: number }> = []; + + // Match fenced code blocks (```...```) + const fencedCodeBlockPattern = /```[a-zA-Z]*\n?[\s\S]*?```/g; + let match; + while ((match = fencedCodeBlockPattern.exec(text)) !== null) { + codeBlockRanges.push({ start: match.index, end: match.index + match[0].length }); + } + + // Match inline code (`code`) + const inlineCodePattern = /`[^`\n]+`/g; + while ((match = inlineCodePattern.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + // Only add if not already inside a fenced code block + const isInsideFenced = codeBlockRanges.some(range => start >= range.start && end <= range.end); + if (!isInsideFenced) { + codeBlockRanges.push({ start, end }); + } + } + + const wikilinkRegex = /\[\[([^\]]+)\]\]/g; + let result = text; + const replacements: Array<{ start: number; end: number; replacement: string }> = []; + + // Find all wikilinks + while ((match = wikilinkRegex.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + + // Skip if inside a code block + const isInsideCode = codeBlockRanges.some(range => start >= range.start && end <= range.end); + if (isInsideCode) { + continue; + } + + const content = match[1]; + // Check if it has pipe syntax: [[target|display]] + const pipeIndex = content.indexOf('|'); + let targetText: string; + let displayText: string; + + if (pipeIndex !== -1) { + targetText = content.slice(0, pipeIndex).trim(); + displayText = content.slice(pipeIndex + 1).trim(); + } else { + targetText = content.trim(); + displayText = content.trim(); + } + + // Normalize the d-tag + const normalizedDTag = normalizeDTag(targetText); + const encodedDTag = encodeURIComponent(normalizedDTag); + const escapedDisplay = displayText.replace(/\]/g, '\\]'); + + // Convert to markdown link format + replacements.push({ + start, + end, + replacement: `[${escapedDisplay}](/find?q=${encodedDTag})` + }); + } + + // Apply replacements in reverse order to preserve indices + for (let i = replacements.length - 1; i >= 0; i--) { + const { start, end, replacement } = replacements[i]; + result = result.slice(0, start) + replacement + result.slice(end); + } + + return result; + } + // Process content: replace nostr URIs with HTML span elements and convert media URLs function processContent(text: string): string { // First, convert greentext (must be before markdown processing) let processed = convertGreentext(text); + // Convert wikilinks to markdown links (before other processing) + processed = convertWikilinks(processed); + // Then, replace emoji shortcodes with images if resolved processed = replaceEmojis(processed); @@ -591,8 +694,11 @@ // Ensure content is defined (TypeScript narrowing) const contentToRender: string = content; + // Include cache version in cache key to invalidate old cached versions + const cacheKey = `${MARKDOWN_CACHE_VERSION}:${contentToRender}`; + // Check IndexedDB cache first (persistent) - const cachedFromDB = await getCachedMarkdown(contentToRender); + const cachedFromDB = await getCachedMarkdown(cacheKey); if (cachedFromDB) { // Also update in-memory cache for faster subsequent access if (markdownCache.size >= MAX_CACHE_SIZE) { @@ -602,12 +708,12 @@ markdownCache.delete(firstKey); } } - markdownCache.set(contentToRender, cachedFromDB); + markdownCache.set(cacheKey, cachedFromDB); return cachedFromDB; } // Check in-memory cache (faster for same session) - const cached = markdownCache.get(contentToRender); + const cached = markdownCache.get(cacheKey); if (cached !== undefined) { return cached; } @@ -773,10 +879,10 @@ markdownCache.delete(firstKey); } } - markdownCache.set(contentToRender, sanitized); + markdownCache.set(cacheKey, sanitized); // Cache in IndexedDB asynchronously (don't await to avoid blocking) - cacheMarkdown(contentToRender, sanitized).catch(err => { + cacheMarkdown(cacheKey, sanitized).catch(err => { console.debug('Failed to cache markdown in IndexedDB:', err); }); @@ -794,7 +900,8 @@ } // Start with in-memory cache for instant display - const cached = markdownCache.get(content); + const cacheKey = `${MARKDOWN_CACHE_VERSION}:${content}`; + const cached = markdownCache.get(cacheKey); if (cached) { renderedHtml = cached; } diff --git a/src/lib/components/find/SearchAddressableEvents.svelte b/src/lib/components/find/SearchAddressableEvents.svelte index 838a7ae..bb2a8e4 100644 --- a/src/lib/components/find/SearchAddressableEvents.svelte +++ b/src/lib/components/find/SearchAddressableEvents.svelte @@ -8,6 +8,7 @@ import { getKindInfo, KIND_LOOKUP, isParameterizedReplaceableKind } from '../../types/kind-lookup.js'; import { nip19 } from 'nostr-tools'; import { goto } from '$app/navigation'; + import { untrack } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; let searchQuery = $state(''); @@ -180,11 +181,19 @@ // Debounced cache search let isSearchingCache = $state(false); + let lastProcessedQuery = $state(''); $effect(() => { // Only react to searchQuery changes, not cacheResults const query = searchQuery.trim(); + // Prevent infinite loops - only process if query actually changed + if (query === lastProcessedQuery) { + return; + } + + lastProcessedQuery = query; + // Clear previous timeout if (cacheSearchTimeoutId) { clearTimeout(cacheSearchTimeoutId); @@ -193,19 +202,26 @@ if (query) { cacheSearchTimeoutId = setTimeout(() => { - if (!isSearchingCache) { - isSearchingCache = true; - searchCache().finally(() => { - isSearchingCache = false; - }); - } + // Use untrack to prevent reactive loops when updating state + untrack(() => { + if (!isSearchingCache) { + isSearchingCache = true; + searchCache().finally(() => { + untrack(() => { + isSearchingCache = false; + }); + }); + } + }); }, CACHE_SEARCH_DEBOUNCE); } else { // Only clear if we're not currently searching - if (!isSearchingCache) { - cacheResults = []; - cacheResultsMap.clear(); - } + untrack(() => { + if (!isSearchingCache) { + cacheResults = []; + cacheResultsMap.clear(); + } + }); } return () => { @@ -285,71 +301,97 @@ const queryLower = query.toLowerCase(); // Normalize query: remove spaces, handle hyphens const normalizedQuery = queryLower.replace(/\s+/g, '-'); - const queryWords = queryLower.split(/[\s-]+/).filter(w => w.length > 0); const relays = relayManager.getProfileReadRelays(); - const allQueries: any[] = []; - - // Build queries with tag filters for better search results - // Only query the specific parameterized replaceable kinds we know about + // Fast path: if query looks like a d-tag (simple alphanumeric with hyphens, no spaces), + // do a direct, fast search for that d-tag only + const looksLikeDTag = /^[a-z0-9-]+$/.test(queryLower) && !queryLower.includes(' '); - // Search by d-tag (most specific) - try both original and normalized - // Also try each word as a potential d-tag match - allQueries.push( - { kinds: parameterizedKinds, '#d': [queryLower], limit: 50 }, - { kinds: parameterizedKinds, '#d': [normalizedQuery], limit: 50 } - ); - for (const word of queryWords) { - if (word.length > 2) { // Only search for words longer than 2 chars - allQueries.push({ kinds: parameterizedKinds, '#d': [word], limit: 50 }); + if (looksLikeDTag) { + // Fast, direct search for d-tag - query only parameterized replaceable kinds from KIND_LOOKUP + // parameterizedKinds is already filtered from KIND_LOOKUP to only include defined kinds + const dTagQueries = normalizedQuery === queryLower + ? [{ kinds: parameterizedKinds, '#d': [queryLower], limit: 100 }] + : [ + { kinds: parameterizedKinds, '#d': [queryLower], limit: 100 }, + { kinds: parameterizedKinds, '#d': [normalizedQuery], limit: 100 } + ]; + + const events = await nostrClient.fetchEvents( + dTagQueries, + relays, + { + useCache: 'cache-first', + cacheResults: true, + timeout: 5000 // Shorter timeout for fast search + } + ); + + // Process results + for (const event of events) { + if (!resultsMap.has(event.id)) { + const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1] || ''; + addResult(event, dTag || queryLower, 'cache'); + } } - } - - // Search by T-tag (topics/tags) for each word - for (const word of queryWords) { - if (word.length > 0) { - allQueries.push({ kinds: parameterizedKinds, '#T': [word], limit: 50 }); + } else { + // Broader search for non-d-tag queries (searches by T-tag, C-tag, title, etc.) + const queryWords = queryLower.split(/[\s-]+/).filter(w => w.length > 0); + const allQueries: any[] = []; + + // Search by d-tag (most specific) - try both original and normalized + allQueries.push( + { kinds: parameterizedKinds, '#d': [queryLower], limit: 50 }, + { kinds: parameterizedKinds, '#d': [normalizedQuery], limit: 50 } + ); + for (const word of queryWords) { + if (word.length > 2) { // Only search for words longer than 2 chars + allQueries.push({ kinds: parameterizedKinds, '#d': [word], limit: 50 }); + } } - } - - // Search by C-tag (categories) for each word - for (const word of queryWords) { - if (word.length > 0) { - allQueries.push({ kinds: parameterizedKinds, '#C': [word], limit: 50 }); + + // Search by T-tag (topics/tags) for each word + for (const word of queryWords) { + if (word.length > 0) { + allQueries.push({ kinds: parameterizedKinds, '#T': [word], limit: 50 }); + } } - } - - // Search by title tag - allQueries.push({ kinds: parameterizedKinds, '#title': [queryLower], limit: 50 }); - for (const word of queryWords) { - if (word.length > 0) { - allQueries.push({ kinds: parameterizedKinds, '#title': [word], limit: 50 }); + + // Search by C-tag (categories) for each word + for (const word of queryWords) { + if (word.length > 0) { + allQueries.push({ kinds: parameterizedKinds, '#C': [word], limit: 50 }); + } } - } - - // Also do a broader search without tag filters to catch partial matches - // This will find events where the query appears in d-tag but not as exact match - allQueries.push({ kinds: parameterizedKinds, limit: 200 }); - - // Use onUpdateWithRelay to process events as they arrive with relay info - await nostrClient.fetchEvents( - allQueries, - relays, - { - useCache: 'cache-first', - cacheResults: true, - timeout: SEARCH_TIMEOUT, - onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => { - // Process events incrementally as they arrive (limit to 100 total) - for (const { event, relay } of eventsWithRelay) { - if (!resultsMap.has(event.id) && resultsMap.size < 100) { - addResult(event, queryLower, relay); + + // Search by title tag + allQueries.push({ kinds: parameterizedKinds, '#title': [queryLower], limit: 50 }); + for (const word of queryWords) { + if (word.length > 0) { + allQueries.push({ kinds: parameterizedKinds, '#title': [word], limit: 50 }); + } + } + + // Use onUpdateWithRelay to process events as they arrive with relay info + await nostrClient.fetchEvents( + allQueries, + relays, + { + useCache: 'cache-first', + cacheResults: true, + timeout: SEARCH_TIMEOUT, + onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => { + // Process events incrementally as they arrive (limit to 100 total) + for (const { event, relay } of eventsWithRelay) { + if (!resultsMap.has(event.id) && resultsMap.size < 100) { + addResult(event, queryLower, relay); + } } } } - } - ); + ); + } // Clear timeout if fetch completes before timeout if (timeoutId) { @@ -395,11 +437,8 @@ } function handleResultClick(event: NostrEvent) { - const dTag = getTagValue(event, 'd'); - if (dTag) { - // Navigate to /replaceable/[d_tag] route - goto(`/replaceable/${encodeURIComponent(dTag)}`); - } + // Navigate to /event route with event ID + goto(`/event/${event.id}`); } function handleResultKeydown(e: KeyboardEvent, event: NostrEvent) { @@ -436,6 +475,28 @@ export function hasActiveSearch(): boolean { return searching || searchQuery.trim().length > 0 || results.length > 0 || cacheResults.length > 0; } + + export function setSearchQuery(query: string) { + // Prevent infinite loops - don't update if query hasn't changed + if (searchQuery === query) { + return; + } + + // Use untrack to update searchQuery without immediately triggering reactive effects + // Reset lastProcessedQuery to allow the $effect to process the new query + untrack(() => { + searchQuery = query; + lastProcessedQuery = ''; // Reset to allow effect to process new query + }); + + // Trigger search automatically when query is set programmatically + // Use untrack to prevent reactive loops + if (query.trim()) { + untrack(() => { + search(); + }); + } + } @@ -767,5 +833,39 @@ color: var(--fog-dark-text, #f9fafb); } + .reaction-display { + position: relative; + } + + .reaction-tooltip { + position: fixed; + z-index: 1000; + pointer-events: auto; + max-width: 300px; + } + + .reaction-tooltip-content { + background: var(--fog-surface, #ffffff); + border: 1px solid var(--fog-border, #e5e7eb); + border-radius: 0.5rem; + padding: 0.5rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + gap: 0.375rem; + max-height: 200px; + overflow-y: auto; + } + + :global(.dark) .reaction-tooltip-content { + background: var(--fog-dark-surface, #1f2937); + border-color: var(--fog-dark-border, #374151); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + } + + .reaction-tooltip-badge { + display: flex; + align-items: center; + } diff --git a/src/routes/find/+page.svelte b/src/routes/find/+page.svelte index d8ac3d2..c7cecdb 100644 --- a/src/routes/find/+page.svelte +++ b/src/routes/find/+page.svelte @@ -11,11 +11,14 @@ import type { NostrEvent } from '../../lib/types/nostr.js'; import { onMount } from 'svelte'; import { page } from '$app/stores'; + import { untrack } from 'svelte'; + import { afterNavigate } from '$app/navigation'; import Pagination from '../../lib/components/ui/Pagination.svelte'; import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js'; let normalSearchComponent: { clearSearch: () => void; getSearchResults: () => { events: NostrEvent[]; profiles: string[] } } | null = $state(null); let advancedSearchComponent: { clearSearch: () => void; getSearchResults: () => { events: NostrEvent[]; profiles: string[] } } | null = $state(null); + let addressableSearchComponent: { setSearchQuery: (query: string) => void; clearSearch: () => void } | null = $state(null); // Combined results from both search types let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] }); @@ -79,6 +82,9 @@ if (advancedSearchComponent) { advancedSearchComponent.clearSearch(); } + if (addressableSearchComponent) { + addressableSearchComponent.clearSearch(); + } searchResults = { events: [], profiles: [] }; cacheResults = { events: [], profiles: [] }; @@ -86,8 +92,41 @@ } + // Track last processed query to prevent infinite loops + let lastProcessedQuery = $state(null); + let isInitialized = $state(false); + onMount(async () => { await nostrClient.initialize(); + isInitialized = true; + + // Check for query parameter and trigger search + const queryParam = $page.url.searchParams.get('q'); + if (queryParam && addressableSearchComponent) { + lastProcessedQuery = queryParam; + addressableSearchComponent.setSearchQuery(queryParam); + } + }); + + // React to URL changes using afterNavigate to avoid reactive loops + afterNavigate(({ to }) => { + if (!isInitialized || !addressableSearchComponent || !to) return; + + const queryParam = to.url.searchParams.get('q'); + + // Only update if the query param actually changed + if (queryParam !== lastProcessedQuery) { + lastProcessedQuery = queryParam; + // Use untrack when calling setSearchQuery to prevent reactive loops + const component = addressableSearchComponent; + untrack(() => { + if (queryParam) { + component.setSearchQuery(queryParam); + } else { + component.clearSearch(); + } + }); + } }); @@ -124,7 +163,7 @@
- +
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0 || searchResults.events.length > 0 || searchResults.profiles.length > 0} diff --git a/src/routes/replaceable/[d_tag]/+page.svelte b/src/routes/replaceable/[d_tag]/+page.svelte deleted file mode 100644 index 489f872..0000000 --- a/src/routes/replaceable/[d_tag]/+page.svelte +++ /dev/null @@ -1,335 +0,0 @@ - - -
- -
-
-
-

- Replaceable Events: {dTag} -

-

- {events.length} {events.length === 1 ? 'event' : 'events'} found -

-
- - {#if loading} -
-

Loading events...

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

- {error || 'No replaceable events found with this d-tag.'} -

- {#if dTag && !dTag.startsWith('naddr1')} -

- Tip: If you have an naddr, you can use it directly: /replaceable/naddr1... -

- {/if} -
- {:else} -
- {#each paginatedEvents as event (event.id)} -
navigateToEvent(event)} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - navigateToEvent(event); - } - }} - role="button" - tabindex="0" - > - -
- {/each} -
- {#if events.length > ITEMS_PER_PAGE} - - {/if} - {/if} -
-
- -