7 changed files with 398 additions and 414 deletions
@ -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