7 changed files with 398 additions and 414 deletions
@ -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…
Reference in new issue