Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
1d0d078bb8
  1. 119
      src/lib/components/content/MarkdownRenderer.svelte
  2. 191
      src/lib/components/find/SearchAddressableEvents.svelte
  3. 2
      src/lib/modules/feed/FeedPost.svelte
  4. 16
      src/lib/modules/feed/HighlightCard.svelte
  5. 100
      src/lib/modules/reactions/FeedReactionButtons.svelte
  6. 41
      src/routes/find/+page.svelte
  7. 335
      src/routes/replaceable/[d_tag]/+page.svelte

119
src/lib/components/content/MarkdownRenderer.svelte

@ -34,6 +34,8 @@
let highlightsLoaded = $state(false); let highlightsLoaded = $state(false);
// Cache for rendered markdown to avoid re-rendering same content // 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<string, string>(); const markdownCache = new Map<string, string>();
const MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory bloat const MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory bloat
@ -401,11 +403,112 @@
return processedLines.join('\n'); 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 // Process content: replace nostr URIs with HTML span elements and convert media URLs
function processContent(text: string): string { function processContent(text: string): string {
// First, convert greentext (must be before markdown processing) // First, convert greentext (must be before markdown processing)
let processed = convertGreentext(text); let processed = convertGreentext(text);
// Convert wikilinks to markdown links (before other processing)
processed = convertWikilinks(processed);
// Then, replace emoji shortcodes with images if resolved // Then, replace emoji shortcodes with images if resolved
processed = replaceEmojis(processed); processed = replaceEmojis(processed);
@ -591,8 +694,11 @@
// Ensure content is defined (TypeScript narrowing) // Ensure content is defined (TypeScript narrowing)
const contentToRender: string = content; 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) // Check IndexedDB cache first (persistent)
const cachedFromDB = await getCachedMarkdown(contentToRender); const cachedFromDB = await getCachedMarkdown(cacheKey);
if (cachedFromDB) { if (cachedFromDB) {
// Also update in-memory cache for faster subsequent access // Also update in-memory cache for faster subsequent access
if (markdownCache.size >= MAX_CACHE_SIZE) { if (markdownCache.size >= MAX_CACHE_SIZE) {
@ -602,12 +708,12 @@
markdownCache.delete(firstKey); markdownCache.delete(firstKey);
} }
} }
markdownCache.set(contentToRender, cachedFromDB); markdownCache.set(cacheKey, cachedFromDB);
return cachedFromDB; return cachedFromDB;
} }
// Check in-memory cache (faster for same session) // Check in-memory cache (faster for same session)
const cached = markdownCache.get(contentToRender); const cached = markdownCache.get(cacheKey);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
@ -773,10 +879,10 @@
markdownCache.delete(firstKey); markdownCache.delete(firstKey);
} }
} }
markdownCache.set(contentToRender, sanitized); markdownCache.set(cacheKey, sanitized);
// Cache in IndexedDB asynchronously (don't await to avoid blocking) // 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); console.debug('Failed to cache markdown in IndexedDB:', err);
}); });
@ -794,7 +900,8 @@
} }
// Start with in-memory cache for instant display // 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) { if (cached) {
renderedHtml = cached; renderedHtml = cached;
} }

191
src/lib/components/find/SearchAddressableEvents.svelte

@ -8,6 +8,7 @@
import { getKindInfo, KIND_LOOKUP, isParameterizedReplaceableKind } from '../../types/kind-lookup.js'; import { getKindInfo, KIND_LOOKUP, isParameterizedReplaceableKind } from '../../types/kind-lookup.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
let searchQuery = $state(''); let searchQuery = $state('');
@ -180,11 +181,19 @@
// Debounced cache search // Debounced cache search
let isSearchingCache = $state(false); let isSearchingCache = $state(false);
let lastProcessedQuery = $state<string>('');
$effect(() => { $effect(() => {
// Only react to searchQuery changes, not cacheResults // Only react to searchQuery changes, not cacheResults
const query = searchQuery.trim(); const query = searchQuery.trim();
// Prevent infinite loops - only process if query actually changed
if (query === lastProcessedQuery) {
return;
}
lastProcessedQuery = query;
// Clear previous timeout // Clear previous timeout
if (cacheSearchTimeoutId) { if (cacheSearchTimeoutId) {
clearTimeout(cacheSearchTimeoutId); clearTimeout(cacheSearchTimeoutId);
@ -193,19 +202,26 @@
if (query) { if (query) {
cacheSearchTimeoutId = setTimeout(() => { cacheSearchTimeoutId = setTimeout(() => {
if (!isSearchingCache) { // Use untrack to prevent reactive loops when updating state
isSearchingCache = true; untrack(() => {
searchCache().finally(() => { if (!isSearchingCache) {
isSearchingCache = false; isSearchingCache = true;
}); searchCache().finally(() => {
} untrack(() => {
isSearchingCache = false;
});
});
}
});
}, CACHE_SEARCH_DEBOUNCE); }, CACHE_SEARCH_DEBOUNCE);
} else { } else {
// Only clear if we're not currently searching // Only clear if we're not currently searching
if (!isSearchingCache) { untrack(() => {
cacheResults = []; if (!isSearchingCache) {
cacheResultsMap.clear(); cacheResults = [];
} cacheResultsMap.clear();
}
});
} }
return () => { return () => {
@ -285,71 +301,97 @@
const queryLower = query.toLowerCase(); const queryLower = query.toLowerCase();
// Normalize query: remove spaces, handle hyphens // Normalize query: remove spaces, handle hyphens
const normalizedQuery = queryLower.replace(/\s+/g, '-'); const normalizedQuery = queryLower.replace(/\s+/g, '-');
const queryWords = queryLower.split(/[\s-]+/).filter(w => w.length > 0);
const relays = relayManager.getProfileReadRelays(); const relays = relayManager.getProfileReadRelays();
const allQueries: any[] = []; // 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
// Build queries with tag filters for better search results const looksLikeDTag = /^[a-z0-9-]+$/.test(queryLower) && !queryLower.includes(' ');
// Only query the specific parameterized replaceable kinds we know about
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
}
);
// Search by d-tag (most specific) - try both original and normalized // Process results
// Also try each word as a potential d-tag match for (const event of events) {
allQueries.push( if (!resultsMap.has(event.id)) {
{ kinds: parameterizedKinds, '#d': [queryLower], limit: 50 }, const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1] || '';
{ kinds: parameterizedKinds, '#d': [normalizedQuery], limit: 50 } addResult(event, dTag || queryLower, 'cache');
); }
for (const word of queryWords) { }
if (word.length > 2) { // Only search for words longer than 2 chars } else {
allQueries.push({ kinds: parameterizedKinds, '#d': [word], limit: 50 }); // 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 T-tag (topics/tags) for each word // Search by T-tag (topics/tags) for each word
for (const word of queryWords) { for (const word of queryWords) {
if (word.length > 0) { if (word.length > 0) {
allQueries.push({ kinds: parameterizedKinds, '#T': [word], limit: 50 }); allQueries.push({ kinds: parameterizedKinds, '#T': [word], limit: 50 });
}
} }
}
// Search by C-tag (categories) for each word // Search by C-tag (categories) for each word
for (const word of queryWords) { for (const word of queryWords) {
if (word.length > 0) { if (word.length > 0) {
allQueries.push({ kinds: parameterizedKinds, '#C': [word], limit: 50 }); allQueries.push({ kinds: parameterizedKinds, '#C': [word], limit: 50 });
}
} }
}
// Search by title tag // Search by title tag
allQueries.push({ kinds: parameterizedKinds, '#title': [queryLower], limit: 50 }); allQueries.push({ kinds: parameterizedKinds, '#title': [queryLower], limit: 50 });
for (const word of queryWords) { for (const word of queryWords) {
if (word.length > 0) { if (word.length > 0) {
allQueries.push({ kinds: parameterizedKinds, '#title': [word], limit: 50 }); allQueries.push({ kinds: parameterizedKinds, '#title': [word], limit: 50 });
}
} }
}
// Also do a broader search without tag filters to catch partial matches // Use onUpdateWithRelay to process events as they arrive with relay info
// This will find events where the query appears in d-tag but not as exact match await nostrClient.fetchEvents(
allQueries.push({ kinds: parameterizedKinds, limit: 200 }); allQueries,
relays,
// Use onUpdateWithRelay to process events as they arrive with relay info {
await nostrClient.fetchEvents( useCache: 'cache-first',
allQueries, cacheResults: true,
relays, timeout: SEARCH_TIMEOUT,
{ onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => {
useCache: 'cache-first', // Process events incrementally as they arrive (limit to 100 total)
cacheResults: true, for (const { event, relay } of eventsWithRelay) {
timeout: SEARCH_TIMEOUT, if (!resultsMap.has(event.id) && resultsMap.size < 100) {
onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => { addResult(event, queryLower, relay);
// 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 // Clear timeout if fetch completes before timeout
if (timeoutId) { if (timeoutId) {
@ -395,11 +437,8 @@
} }
function handleResultClick(event: NostrEvent) { function handleResultClick(event: NostrEvent) {
const dTag = getTagValue(event, 'd'); // Navigate to /event route with event ID
if (dTag) { goto(`/event/${event.id}`);
// Navigate to /replaceable/[d_tag] route
goto(`/replaceable/${encodeURIComponent(dTag)}`);
}
} }
function handleResultKeydown(e: KeyboardEvent, event: NostrEvent) { function handleResultKeydown(e: KeyboardEvent, event: NostrEvent) {
@ -436,6 +475,28 @@
export function hasActiveSearch(): boolean { export function hasActiveSearch(): boolean {
return searching || searchQuery.trim().length > 0 || results.length > 0 || cacheResults.length > 0; 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();
});
}
}
</script> </script>
<div class="addressable-search"> <div class="addressable-search">

2
src/lib/modules/feed/FeedPost.svelte

@ -1070,7 +1070,7 @@
{/if} {/if}
{:else if segment.type === 'wikilink' && segment.wikilink} {:else if segment.type === 'wikilink' && segment.wikilink}
<a <a
href="/replaceable/{segment.wikilink}" href="/find?q={encodeURIComponent(segment.wikilink)}"
class="text-fog-accent dark:text-fog-dark-accent hover:underline" class="text-fog-accent dark:text-fog-dark-accent hover:underline"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >

16
src/lib/modules/feed/HighlightCard.svelte

@ -16,6 +16,7 @@
import { getEventLink } from '../../services/event-links.js'; import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte'; import IconButton from '../../components/ui/IconButton.svelte';
import { nip19 } from 'nostr-tools';
interface Props { interface Props {
highlight: NostrEvent; // The highlight event (kind 9802) highlight: NostrEvent; // The highlight event (kind 9802)
@ -96,8 +97,19 @@
const aTagData = parseATag(); const aTagData = parseATag();
if (aTagData) { if (aTagData) {
// Use the d-tag for the replaceable route // Construct naddr from a-tag data and use /event route
return `/replaceable/${aTagData.dTag}`; try {
const naddr = nip19.naddrEncode({
kind: aTagData.kind,
pubkey: aTagData.pubkey,
identifier: aTagData.dTag,
relays: []
});
return `/event/${naddr}`;
} catch (error) {
console.error('Error encoding naddr:', error);
return null;
}
} }
return null; return null;

100
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -14,6 +14,7 @@
import Icon from '../../components/ui/Icon.svelte'; import Icon from '../../components/ui/Icon.svelte';
import { getCachedReactionsForEvents } from '../../services/cache/event-cache.js'; import { getCachedReactionsForEvents } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte'; import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -40,6 +41,9 @@
let updateDebounceTimer: ReturnType<typeof setTimeout> | null = null; let updateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let publicationModalOpen = $state(false); let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let hoveredReaction = $state<string | null>(null);
let hoveredReactionElement = $state<HTMLElement | null>(null);
let tooltipPosition = $state<{ top: number; left: number } | null>(null);
onMount(() => { onMount(() => {
// Set lastEventId immediately to prevent $effect from running during mount // Set lastEventId immediately to prevent $effect from running during mount
@ -637,9 +641,48 @@
{/if} {/if}
{#each getAllReactions() as { content, count }} {#each getAllReactions() as { content, count }}
{@const reactionData = reactions.get(content)}
{@const pubkeys = reactionData ? Array.from(reactionData.pubkeys) : []}
<span <span
class="reaction-display {userReaction === content ? 'active' : ''}" class="reaction-display {userReaction === content ? 'active' : ''}"
title={content === '+' ? 'Liked' : `Reacted with ${content}`} title={content === '+' ? 'Liked' : `Reacted with ${content}`}
onmouseenter={(e) => {
hoveredReaction = content;
hoveredReactionElement = e.currentTarget;
// Calculate tooltip position
const rect = e.currentTarget.getBoundingClientRect();
const tooltipWidth = 300; // max-width from CSS
const tooltipHeight = 200; // max-height from CSS
let left = rect.left;
let top = rect.bottom + 5;
// Adjust if tooltip would go off right edge
if (left + tooltipWidth > window.innerWidth) {
left = window.innerWidth - tooltipWidth - 10;
}
// Adjust if tooltip would go off left edge
if (left < 10) {
left = 10;
}
// Adjust if tooltip would go off bottom edge (show above instead)
if (top + tooltipHeight > window.innerHeight) {
top = rect.top - tooltipHeight - 5;
}
// Adjust if tooltip would go off top edge
if (top < 10) {
top = 10;
}
tooltipPosition = { top, left };
}}
onmouseleave={() => {
hoveredReaction = null;
hoveredReactionElement = null;
tooltipPosition = null;
}}
> >
{#if content === '+'} {#if content === '+'}
<Icon name="heart" size={16} /> <Icon name="heart" size={16} />
@ -660,6 +703,29 @@
{/if} {/if}
<span class="reaction-count-text">{count}</span> <span class="reaction-count-text">{count}</span>
</span> </span>
{#if hoveredReaction === content && tooltipPosition && pubkeys.length > 0}
<div
class="reaction-tooltip"
style="top: {tooltipPosition.top}px; left: {tooltipPosition.left}px;"
onmouseenter={() => {
// Keep tooltip visible when hovering over it
}}
onmouseleave={() => {
hoveredReaction = null;
hoveredReactionElement = null;
tooltipPosition = null;
}}
>
<div class="reaction-tooltip-content">
{#each pubkeys as pubkey}
<div class="reaction-tooltip-badge">
<ProfileBadge {pubkey} inline={true} />
</div>
{/each}
</div>
</div>
{/if}
{/each} {/each}
</div> </div>
@ -767,5 +833,39 @@
color: var(--fog-dark-text, #f9fafb); 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;
}
</style> </style>

41
src/routes/find/+page.svelte

@ -11,11 +11,14 @@
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { untrack } from 'svelte';
import { afterNavigate } from '$app/navigation';
import Pagination from '../../lib/components/ui/Pagination.svelte'; import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js'; import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
let normalSearchComponent: { clearSearch: () => void; getSearchResults: () => { events: NostrEvent[]; profiles: string[] } } | null = $state(null); 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 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 // Combined results from both search types
let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] }); let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] });
@ -79,6 +82,9 @@
if (advancedSearchComponent) { if (advancedSearchComponent) {
advancedSearchComponent.clearSearch(); advancedSearchComponent.clearSearch();
} }
if (addressableSearchComponent) {
addressableSearchComponent.clearSearch();
}
searchResults = { events: [], profiles: [] }; searchResults = { events: [], profiles: [] };
cacheResults = { events: [], profiles: [] }; cacheResults = { events: [], profiles: [] };
@ -86,8 +92,41 @@
} }
// Track last processed query to prevent infinite loops
let lastProcessedQuery = $state<string | null>(null);
let isInitialized = $state(false);
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); 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();
}
});
}
}); });
</script> </script>
@ -124,7 +163,7 @@
</section> </section>
<section class="find-section"> <section class="find-section">
<SearchAddressableEvents /> <SearchAddressableEvents bind:this={addressableSearchComponent} />
</section> </section>
{#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0 || searchResults.events.length > 0 || searchResults.profiles.length > 0} {#if cacheResults.events.length > 0 || cacheResults.profiles.length > 0 || searchResults.events.length > 0 || searchResults.profiles.length > 0}

335
src/routes/replaceable/[d_tag]/+page.svelte

@ -1,335 +0,0 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { goto } from '$app/navigation';
import { nip19 } from 'nostr-tools';
import Pagination from '../../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../../lib/utils/pagination.js';
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let dTag = $derived($page.params.d_tag);
// Pagination
let currentPage = $derived(getCurrentPage($page.url.searchParams));
let paginatedEvents = $derived(
events.length > ITEMS_PER_PAGE
? getPaginatedItems(events, currentPage, ITEMS_PER_PAGE)
: events
);
onMount(async () => {
await nostrClient.initialize();
await loadReplaceableEvents();
});
$effect(() => {
if ($page.params.d_tag) {
loadReplaceableEvents();
}
});
async function loadReplaceableEvents() {
if (!dTag) return;
loading = true;
error = null;
try {
// Use both feed and profile relays for wider coverage
const profileRelays = relayManager.getProfileReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const relays = [...new Set([...profileRelays, ...feedRelays])]; // Deduplicate
const allEvents: NostrEvent[] = [];
// First, check cache for events with this d-tag
try {
const { getEventsByKind } = await import('../../../lib/services/cache/event-cache.js');
// Check parameterized replaceable range in cache
// Query each kind individually since getEventsByKind takes a single kind number
for (let kind = 30000; kind < 40000; kind++) {
const cached = await getEventsByKind(kind);
const matching = cached.filter(e => {
const eventDTag = e.tags.find(t => t[0] === 'd')?.[1] || '';
return eventDTag === dTag;
});
if (matching.length > 0) {
allEvents.push(...matching);
}
}
} catch (cacheError) {
// Cache error (non-critical)
}
// First, try to decode as naddr (if the d-tag is actually an naddr)
let decodedNaddr: { kind: number; pubkey: string; identifier?: string; relays?: string[] } | null = null;
if (/^naddr1[a-z0-9]+$/i.test(dTag)) {
try {
const decoded = nip19.decode(dTag);
if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data) {
decodedNaddr = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] };
}
} catch (e) {
// Not an naddr, treating as d-tag
}
}
// If we decoded an naddr, fetch directly by kind, pubkey, and d-tag
if (decodedNaddr) {
const naddrRelays = decodedNaddr.relays && decodedNaddr.relays.length > 0
? decodedNaddr.relays
: relays;
const filter: any = {
kinds: [decodedNaddr.kind],
authors: [decodedNaddr.pubkey],
limit: 1
};
if (decodedNaddr.identifier) {
filter['#d'] = [decodedNaddr.identifier];
}
const naddrEvents = await nostrClient.fetchEvents(
[filter],
naddrRelays,
{ useCache: true, cacheResults: true }
);
if (naddrEvents.length > 0) {
events = naddrEvents;
loading = false;
return;
} else {
error = `Event not found for naddr. Tried kind ${decodedNaddr.kind}, pubkey ${decodedNaddr.pubkey.substring(0, 16)}..., d-tag: ${decodedNaddr.identifier || 'none'}`;
}
}
// If not an naddr or naddr lookup failed, search by d-tag
// Focus on parameterized replaceable events (30000-39999) first since they commonly use d-tags
// Query parameterized replaceable range (30000-39999) - these are most likely to have d-tags
// Use smaller batches to avoid relay limits
const BATCH_SIZE = 100;
for (let start = 30000; start < 40000; start += BATCH_SIZE) {
const batchKinds: number[] = [];
for (let kind = start; kind < Math.min(start + BATCH_SIZE, 40000); kind++) {
batchKinds.push(kind);
}
try {
const batchEvents = await nostrClient.fetchEvents(
[{ kinds: batchKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
allEvents.push(...batchEvents);
} catch (e) {
// Query failed for kind range (non-critical)
}
}
// If we found events in cache, extract relay hints from their tags and query those relays too
const additionalRelays = new Set<string>();
for (const event of allEvents) {
// Extract relay hints from r tags
const rTags = event.tags.filter(t => t[0] === 'r' && t[1]);
for (const rTag of rTags) {
if (rTag[1] && (rTag[1].startsWith('ws://') || rTag[1].startsWith('wss://'))) {
additionalRelays.add(rTag[1]);
}
}
// Extract relay hints from a tags (third element is often a relay)
const aTags = event.tags.filter(t => t[0] === 'a' && t.length > 2);
for (const aTag of aTags) {
if (aTag[2] && (aTag[2].startsWith('ws://') || aTag[2].startsWith('wss://'))) {
additionalRelays.add(aTag[2]);
}
}
}
// Query additional relays if we found any
if (additionalRelays.size > 0) {
const additionalRelaysArray = Array.from(additionalRelays);
// Query parameterized replaceable range on additional relays
for (let start = 30000; start < 40000; start += BATCH_SIZE) {
const batchKinds: number[] = [];
for (let kind = start; kind < Math.min(start + BATCH_SIZE, 40000); kind++) {
batchKinds.push(kind);
}
try {
const batchEvents = await nostrClient.fetchEvents(
[{ kinds: batchKinds, '#d': [dTag], limit: 100 }],
additionalRelaysArray,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
allEvents.push(...batchEvents);
} catch (e) {
// Query failed for hint relays (non-critical)
}
}
}
if (allEvents.length > 0) {
}
// Also check common replaceable kinds that might have d-tags
const commonKinds = [0, 3, 10000, 10001, 10002, 10003, 10004, 10005, 10006, 10007, 10008, 10009, 10010, 10030, 10031, 10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, 10045, 10046, 10047, 10048, 10049, 10050, 10133, 10432, 30315];
const commonEvents = await nostrClient.fetchEvents(
[{ kinds: commonKinds, '#d': [dTag], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
allEvents.push(...commonEvents);
// For replaceable events, get the newest version of each (by pubkey and kind)
// For parameterized replaceable, get newest by (pubkey, kind, d-tag)
const eventsByKey = new Map<string, NostrEvent>();
for (const event of allEvents) {
// Key is pubkey:kind for replaceable, pubkey:kind:d-tag for parameterized
const isParamReplaceable = event.kind >= 30000 && event.kind < 40000;
const eventDTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = isParamReplaceable
? `${event.pubkey}:${event.kind}:${eventDTag}`
: `${event.pubkey}:${event.kind}`;
const existing = eventsByKey.get(key);
if (!existing || event.created_at > existing.created_at) {
eventsByKey.set(key, event);
}
}
// Sort by created_at descending
events = Array.from(eventsByKey.values()).sort((a, b) => b.created_at - a.created_at);
if (events.length === 0 && !error) {
error = `No replaceable events found with d-tag "${dTag}". The event might not be on the queried relays, or the d-tag might be incorrect.`;
}
} catch (err) {
// Failed to load replaceable events
error = err instanceof Error ? err.message : 'Failed to load replaceable events';
events = [];
} finally {
loading = false;
}
}
function navigateToEvent(event: NostrEvent) {
goto(`/event/${event.id}`);
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="replaceable-content">
<div class="replaceable-header mb-6">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">
Replaceable Events: {dTag}
</h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">
{events.length} {events.length === 1 ? 'event' : 'events'} found
</p>
</div>
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading events...</p>
</div>
{:else if events.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">
{error || 'No replaceable events found with this d-tag.'}
</p>
{#if dTag && !dTag.startsWith('naddr1')}
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2 text-sm">
Tip: If you have an naddr, you can use it directly: <code>/replaceable/naddr1...</code>
</p>
{/if}
</div>
{:else}
<div class="events-list">
{#each paginatedEvents as event (event.id)}
<div
class="event-item"
onclick={() => navigateToEvent(event)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigateToEvent(event);
}
}}
role="button"
tabindex="0"
>
<FeedPost post={event} />
</div>
{/each}
</div>
{#if events.length > ITEMS_PER_PAGE}
<Pagination totalItems={events.length} itemsPerPage={ITEMS_PER_PAGE} />
{/if}
{/if}
</div>
</main>
<style>
.replaceable-content {
max-width: var(--content-width);
margin: 0 auto;
}
.replaceable-header {
padding: 0 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
padding-bottom: 1rem;
}
:global(.dark) .replaceable-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.loading-state,
.empty-state {
padding: 2rem;
text-align: center;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-item {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
cursor: pointer;
transition: all 0.2s;
}
:global(.dark) .event-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-item:hover {
border-color: var(--fog-accent, #64748b);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.dark) .event-item:hover {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style>
Loading…
Cancel
Save