Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
1d0d078bb8
  1. 119
      src/lib/components/content/MarkdownRenderer.svelte
  2. 89
      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 @@ @@ -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<string, string>();
const MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory bloat
@ -401,11 +403,112 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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;
}

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

@ -8,6 +8,7 @@ @@ -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 @@ @@ -180,11 +181,19 @@
// Debounced cache search
let isSearchingCache = $state(false);
let lastProcessedQuery = $state<string>('');
$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 @@ @@ -193,19 +202,26 @@
if (query) {
cacheSearchTimeoutId = setTimeout(() => {
// 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
untrack(() => {
if (!isSearchingCache) {
cacheResults = [];
cacheResultsMap.clear();
}
});
}
return () => {
@ -285,17 +301,46 @@ @@ -285,17 +301,46 @@
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[] = [];
// 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(' ');
// Build queries with tag filters for better search results
// 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
}
);
// 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');
}
}
} 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
// Also try each word as a potential d-tag match
allQueries.push(
{ kinds: parameterizedKinds, '#d': [queryLower], limit: 50 },
{ kinds: parameterizedKinds, '#d': [normalizedQuery], limit: 50 }
@ -328,10 +373,6 @@ @@ -328,10 +373,6 @@
}
}
// 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,
@ -350,6 +391,7 @@ @@ -350,6 +391,7 @@
}
}
);
}
// Clear timeout if fetch completes before timeout
if (timeoutId) {
@ -395,11 +437,8 @@ @@ -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 @@ @@ -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();
});
}
}
</script>
<div class="addressable-search">

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

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

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

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
import { nip19 } from 'nostr-tools';
interface Props {
highlight: NostrEvent; // The highlight event (kind 9802)
@ -96,8 +97,19 @@ @@ -96,8 +97,19 @@
const aTagData = parseATag();
if (aTagData) {
// Use the d-tag for the replaceable route
return `/replaceable/${aTagData.dTag}`;
// Construct naddr from a-tag data and use /event route
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;

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

@ -14,6 +14,7 @@ @@ -14,6 +14,7 @@
import Icon from '../../components/ui/Icon.svelte';
import { getCachedReactionsForEvents } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
interface Props {
event: NostrEvent;
@ -40,6 +41,9 @@ @@ -40,6 +41,9 @@
let updateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let publicationModalOpen = $state(false);
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(() => {
// Set lastEventId immediately to prevent $effect from running during mount
@ -637,9 +641,48 @@ @@ -637,9 +641,48 @@
{/if}
{#each getAllReactions() as { content, count }}
{@const reactionData = reactions.get(content)}
{@const pubkeys = reactionData ? Array.from(reactionData.pubkeys) : []}
<span
class="reaction-display {userReaction === content ? 'active' : ''}"
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 === '+'}
<Icon name="heart" size={16} />
@ -660,6 +703,29 @@ @@ -660,6 +703,29 @@
{/if}
<span class="reaction-count-text">{count}</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}
</div>
@ -767,5 +833,39 @@ @@ -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;
}
</style>

41
src/routes/find/+page.svelte

@ -11,11 +11,14 @@ @@ -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 @@ @@ -79,6 +82,9 @@
if (advancedSearchComponent) {
advancedSearchComponent.clearSearch();
}
if (addressableSearchComponent) {
addressableSearchComponent.clearSearch();
}
searchResults = { events: [], profiles: [] };
cacheResults = { events: [], profiles: [] };
@ -86,8 +92,41 @@ @@ -86,8 +92,41 @@
}
// Track last processed query to prevent infinite loops
let lastProcessedQuery = $state<string | null>(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();
}
});
}
});
</script>
@ -124,7 +163,7 @@ @@ -124,7 +163,7 @@
</section>
<section class="find-section">
<SearchAddressableEvents />
<SearchAddressableEvents bind:this={addressableSearchComponent} />
</section>
{#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 @@ @@ -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