19 changed files with 950 additions and 538 deletions
@ -0,0 +1,207 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; |
||||||
|
import { getMimeTags, getEventType } from "$lib/utils/mime"; |
||||||
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||||
|
import { toNpub } from "$lib/utils/nostrUtils"; |
||||||
|
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; |
||||||
|
import { standardRelays } from "$lib/consts"; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import { getMatchingTags } from '$lib/utils/nostrUtils'; |
||||||
|
|
||||||
|
const { event, profile = null } = $props<{ |
||||||
|
event: NDKEvent; |
||||||
|
profile?: { |
||||||
|
name?: string; |
||||||
|
display_name?: string; |
||||||
|
about?: string; |
||||||
|
picture?: string; |
||||||
|
banner?: string; |
||||||
|
website?: string; |
||||||
|
lud16?: string; |
||||||
|
nip05?: string; |
||||||
|
} | null; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let showFullContent = $state(false); |
||||||
|
let parsedContent = $state(''); |
||||||
|
let contentPreview = $state(''); |
||||||
|
|
||||||
|
function getEventTitle(event: NDKEvent): string { |
||||||
|
return getMatchingTags(event, 'title')[0]?.[1] || 'Untitled'; |
||||||
|
} |
||||||
|
|
||||||
|
function getEventSummary(event: NDKEvent): string { |
||||||
|
return getMatchingTags(event, 'summary')[0]?.[1] || ''; |
||||||
|
} |
||||||
|
|
||||||
|
function getEventHashtags(event: NDKEvent): string[] { |
||||||
|
return getMatchingTags(event, 't').map((tag: string[]) => tag[1]); |
||||||
|
} |
||||||
|
|
||||||
|
function getEventTypeDisplay(event: NDKEvent): string { |
||||||
|
const [mTag, MTag] = getMimeTags(event.kind || 0); |
||||||
|
return MTag[1].split('/')[1] || `Event Kind ${event.kind}`; |
||||||
|
} |
||||||
|
|
||||||
|
function renderTag(tag: string[]): string { |
||||||
|
if (tag[0] === 'a' && tag.length > 1) { |
||||||
|
const [kind, pubkey, d] = tag[1].split(':'); |
||||||
|
return `<a href='/events?id=${naddrEncode({kind: +kind, pubkey, tags: [['d', d]], content: '', id: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>a:${tag[1]}</a>`; |
||||||
|
} else if (tag[0] === 'e' && tag.length > 1) { |
||||||
|
return `<a href='/events?id=${neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>e:${tag[1]}</a>`; |
||||||
|
} else { |
||||||
|
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (event && event.kind !== 0 && event.content) { |
||||||
|
parseBasicmarkup(event.content).then(html => { |
||||||
|
parsedContent = html; |
||||||
|
contentPreview = html.slice(0, 250); |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col space-y-4"> |
||||||
|
{#if event.kind !== 0 && getEventTitle(event)} |
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{getEventTitle(event)}</h2> |
||||||
|
{:else if event.kind === 0 && profile && profile.name} |
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{profile.name}</h2> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
{#if toNpub(event.pubkey)} |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Author: {@render userBadge(toNpub(event.pubkey) as string, profile?.display_name || event.pubkey)}</span> |
||||||
|
{:else} |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Author: {profile?.display_name || event.pubkey}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Kind:</span> |
||||||
|
<span class="font-mono">{event.kind}</span> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">({getEventTypeDisplay(event)})</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if getEventSummary(event)} |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Summary:</span> |
||||||
|
<p class="text-gray-800 dark:text-gray-200">{getEventSummary(event)}</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if getEventHashtags(event).length} |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Tags:</span> |
||||||
|
<div class="flex flex-wrap gap-2"> |
||||||
|
{#each getEventHashtags(event) as tag} |
||||||
|
<span class="px-2 py-1 rounded bg-primary-100 text-primary-700 text-sm font-medium">#{tag}</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Content --> |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Content:</span> |
||||||
|
{#if event.kind === 0} |
||||||
|
{#if profile} |
||||||
|
<div class="bg-primary-50 dark:bg-primary-900 rounded-lg p-6 mt-2 shadow flex flex-col gap-4"> |
||||||
|
<dl class="grid grid-cols-1 gap-y-2"> |
||||||
|
{#if profile.name} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Name:</dt> |
||||||
|
<dd>{profile.name}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.display_name} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Display Name:</dt> |
||||||
|
<dd>{profile.display_name}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.about} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">About:</dt> |
||||||
|
<dd class="whitespace-pre-line">{profile.about}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.picture} |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Picture:</dt> |
||||||
|
<dd> |
||||||
|
<img src={profile.picture} alt="Profile" class="w-16 h-16 rounded-full border" onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} /> |
||||||
|
</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.banner} |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Banner:</dt> |
||||||
|
<dd> |
||||||
|
<img src={profile.banner} alt="Banner" class="w-full max-w-xs rounded border" onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} /> |
||||||
|
</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.website} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Website:</dt> |
||||||
|
<dd> |
||||||
|
<a href={profile.website} target="_blank" class="underline text-primary-700">{profile.website}</a> |
||||||
|
</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.lud16} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt> |
||||||
|
<dd>{profile.lud16}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.nip05} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">NIP-05:</dt> |
||||||
|
<dd>{profile.nip05}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</dl> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<pre class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-2 mt-2">{event.content}</pre> |
||||||
|
{/if} |
||||||
|
{:else} |
||||||
|
<div class="prose dark:prose-invert max-w-none"> |
||||||
|
{@html showFullContent ? parsedContent : contentPreview} |
||||||
|
{#if !showFullContent && parsedContent.length > 250} |
||||||
|
<button class="mt-2 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300" onclick={() => showFullContent = true}>Show more</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Tags Array --> |
||||||
|
{#if event.tags && event.tags.length} |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Event Tags:</span> |
||||||
|
<div class="flex flex-wrap gap-2"> |
||||||
|
{#each event.tags as tag} |
||||||
|
{@html renderTag(tag)} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Raw Event JSON --> |
||||||
|
<details class="bg-primary-50 dark:bg-primary-900 rounded p-4"> |
||||||
|
<summary class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"> |
||||||
|
Show Raw Event JSON |
||||||
|
</summary> |
||||||
|
<pre |
||||||
|
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono" |
||||||
|
style="line-height: 1.7; font-size: 1rem;" |
||||||
|
> |
||||||
|
{JSON.stringify(event.rawEvent(), null, 2)} |
||||||
|
</pre> |
||||||
|
</details> |
||||||
|
</div> |
||||||
@ -0,0 +1,218 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Input, Button } from "flowbite-svelte"; |
||||||
|
import { ndkInstance } from "$lib/ndk"; |
||||||
|
import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; |
||||||
|
import { nip19 } from '$lib/utils/nostrUtils'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import { NDKRelaySet } from '@nostr-dev-kit/ndk'; |
||||||
|
import { standardRelays, fallbackRelays } from '$lib/consts'; |
||||||
|
import RelayDisplay from './RelayDisplay.svelte'; |
||||||
|
|
||||||
|
const { loading, error, searchValue, onEventFound, event } = $props<{ |
||||||
|
loading: boolean; |
||||||
|
error: string | null; |
||||||
|
searchValue: string | null; |
||||||
|
onEventFound: (event: NDKEvent) => void; |
||||||
|
event: NDKEvent | null; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let searchQuery = $state(""); |
||||||
|
let localError = $state<string | null>(null); |
||||||
|
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); |
||||||
|
let foundEvent = $state<NDKEvent | null>(null); |
||||||
|
let searching = $state(false); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (searchValue) { |
||||||
|
searchEvent(false, searchValue); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
foundEvent = event; |
||||||
|
}); |
||||||
|
|
||||||
|
async function searchEvent(clearInput: boolean = true, queryOverride?: string) { |
||||||
|
localError = null; |
||||||
|
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim(); |
||||||
|
if (!query) return; |
||||||
|
|
||||||
|
// Only update the URL if this is a manual search |
||||||
|
if (clearInput) { |
||||||
|
const encoded = encodeURIComponent(query); |
||||||
|
goto(`?id=${encoded}`, { replaceState: false, keepFocus: true, noScroll: true }); |
||||||
|
} |
||||||
|
|
||||||
|
if (clearInput) { |
||||||
|
searchQuery = ''; |
||||||
|
} |
||||||
|
|
||||||
|
// Clean the query |
||||||
|
let cleanedQuery = query.replace(/^nostr:/, ''); |
||||||
|
let filterOrId: any = cleanedQuery; |
||||||
|
console.log('[Events] Cleaned query:', cleanedQuery); |
||||||
|
|
||||||
|
// If it's a 64-char hex, try as event id first, then as pubkey (profile) |
||||||
|
if (/^[a-f0-9]{64}$/i.test(cleanedQuery)) { |
||||||
|
// Try as event id |
||||||
|
filterOrId = cleanedQuery; |
||||||
|
const event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
// Try as pubkey (profile event) |
||||||
|
filterOrId = { kinds: [0], authors: [cleanedQuery] }; |
||||||
|
const profileEvent = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); |
||||||
|
if (profileEvent) { |
||||||
|
handleFoundEvent(profileEvent); |
||||||
|
} |
||||||
|
} else { |
||||||
|
handleFoundEvent(event); |
||||||
|
} |
||||||
|
return; |
||||||
|
} else if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)) { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(cleanedQuery); |
||||||
|
if (!decoded) throw new Error('Invalid identifier'); |
||||||
|
console.log('[Events] Decoded NIP-19:', decoded); |
||||||
|
switch (decoded.type) { |
||||||
|
case 'nevent': |
||||||
|
filterOrId = decoded.data.id; |
||||||
|
break; |
||||||
|
case 'note': |
||||||
|
filterOrId = decoded.data; |
||||||
|
break; |
||||||
|
case 'naddr': |
||||||
|
filterOrId = { |
||||||
|
kinds: [decoded.data.kind], |
||||||
|
authors: [decoded.data.pubkey], |
||||||
|
'#d': [decoded.data.identifier], |
||||||
|
}; |
||||||
|
break; |
||||||
|
case 'nprofile': |
||||||
|
filterOrId = { |
||||||
|
kinds: [0], |
||||||
|
authors: [decoded.data.pubkey], |
||||||
|
}; |
||||||
|
break; |
||||||
|
case 'npub': |
||||||
|
filterOrId = { |
||||||
|
kinds: [0], |
||||||
|
authors: [decoded.data], |
||||||
|
}; |
||||||
|
break; |
||||||
|
default: |
||||||
|
filterOrId = cleanedQuery; |
||||||
|
} |
||||||
|
console.log('[Events] Using filterOrId:', filterOrId); |
||||||
|
} catch (e) { |
||||||
|
console.error('[Events] Invalid Nostr identifier:', cleanedQuery, e); |
||||||
|
localError = 'Invalid Nostr identifier.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
console.log('Searching for event:', filterOrId); |
||||||
|
const event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
console.warn('[Events] Event not found for filterOrId:', filterOrId); |
||||||
|
localError = 'Event not found'; |
||||||
|
} else { |
||||||
|
console.log('[Events] Event found:', event); |
||||||
|
handleFoundEvent(event); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('[Events] Error fetching event:', err, 'Query:', query); |
||||||
|
localError = 'Error fetching event. Please check the ID and try again.'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function resilientSearch(filterOrId: any) { |
||||||
|
const ndk = $ndkInstance; |
||||||
|
const allRelays = [ |
||||||
|
...standardRelays, |
||||||
|
...Array.from(ndk.pool?.relays.values() || []).map(r => r.url), |
||||||
|
...fallbackRelays |
||||||
|
].filter((url, idx, arr) => arr.indexOf(url) === idx); |
||||||
|
|
||||||
|
relayStatuses = Object.fromEntries(allRelays.map(r => [r, 'pending'])); |
||||||
|
foundEvent = null; |
||||||
|
|
||||||
|
await Promise.all( |
||||||
|
allRelays.map(async (relay) => { |
||||||
|
try { |
||||||
|
const relaySet = NDKRelaySet.fromRelayUrls(allRelays, ndk); |
||||||
|
const event = await ndk.fetchEvent( |
||||||
|
typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId, |
||||||
|
undefined, |
||||||
|
relaySet |
||||||
|
).withTimeout(2500); |
||||||
|
|
||||||
|
if (event && !foundEvent) { |
||||||
|
foundEvent = event; |
||||||
|
handleFoundEvent(event); |
||||||
|
} |
||||||
|
relayStatuses = { ...relayStatuses, [relay]: event ? 'found' : 'notfound' }; |
||||||
|
} catch { |
||||||
|
relayStatuses = { ...relayStatuses, [relay]: 'notfound' }; |
||||||
|
} |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function searchForEvent(value: string) { |
||||||
|
searchEvent(false); |
||||||
|
} |
||||||
|
|
||||||
|
function handleFoundEvent(event: NDKEvent) { |
||||||
|
foundEvent = event; |
||||||
|
onEventFound(event); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col space-y-6"> |
||||||
|
<div class="flex gap-2"> |
||||||
|
<Input |
||||||
|
bind:value={searchQuery} |
||||||
|
placeholder="Enter event ID, nevent, or naddr..." |
||||||
|
class="flex-grow" |
||||||
|
on:keydown={(e: KeyboardEvent) => e.key === 'Enter' && searchEvent(true)} |
||||||
|
/> |
||||||
|
<Button on:click={() => searchEvent(true)} disabled={loading}> |
||||||
|
{loading ? 'Searching...' : 'Search'} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if localError || error} |
||||||
|
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> |
||||||
|
{localError || error} |
||||||
|
{#if searchQuery.trim()} |
||||||
|
<div class="mt-2"> |
||||||
|
You can also try viewing this event on |
||||||
|
<a |
||||||
|
class="underline text-primary-700" |
||||||
|
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())} |
||||||
|
target="_blank" |
||||||
|
rel="noopener" |
||||||
|
>Njump</a>. |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="mt-4"> |
||||||
|
<div class="flex flex-wrap gap-2"> |
||||||
|
{#each Object.entries(relayStatuses) as [relay, status]} |
||||||
|
<RelayDisplay {relay} showStatus={true} status={status} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{#if !foundEvent && Object.values(relayStatuses).some(s => s === 'pending')} |
||||||
|
<div class="text-gray-500 mt-2">Searching relays...</div> |
||||||
|
{/if} |
||||||
|
{#if !foundEvent && !searching && Object.values(relayStatuses).every(s => s !== 'pending')} |
||||||
|
<div class="text-red-500 mt-2">Event not found on any relay.</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,243 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Button } from "flowbite-svelte"; |
||||||
|
import { ndkInstance } from "$lib/ndk"; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte'; |
||||||
|
import { standardRelays, fallbackRelays } from "$lib/consts"; |
||||||
|
import NDK from '@nostr-dev-kit/ndk'; |
||||||
|
|
||||||
|
const { event } = $props<{ |
||||||
|
event: NDKEvent; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let searchingRelays = $state(false); |
||||||
|
let foundRelays = $state<string[]>([]); |
||||||
|
let broadcasting = $state(false); |
||||||
|
let broadcastSuccess = $state(false); |
||||||
|
let broadcastError = $state<string | null>(null); |
||||||
|
let showRelayModal = $state(false); |
||||||
|
let relaySearchResults = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); |
||||||
|
let allRelays = $state<string[]>([]); |
||||||
|
|
||||||
|
// Magnifying glass icon SVG |
||||||
|
const searchIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> |
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/> |
||||||
|
</svg>`; |
||||||
|
|
||||||
|
// Broadcast icon SVG |
||||||
|
const broadcastIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"/> |
||||||
|
</svg>`; |
||||||
|
|
||||||
|
async function searchRelays() { |
||||||
|
if (!event) return; |
||||||
|
const currentEvent = event; // Store reference to avoid null checks |
||||||
|
searchingRelays = true; |
||||||
|
foundRelays = []; |
||||||
|
try { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk) throw new Error('NDK not initialized'); |
||||||
|
|
||||||
|
// Get all relays from the pool |
||||||
|
const allRelays = Array.from(ndk.pool?.relays.values() || []) |
||||||
|
.map(r => r.url) |
||||||
|
.concat(standardRelays) |
||||||
|
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates |
||||||
|
|
||||||
|
// Try to fetch the event from each relay |
||||||
|
const results = await Promise.allSettled( |
||||||
|
allRelays.map(async (relay) => { |
||||||
|
const relaySet = createRelaySetFromUrls([relay], ndk); |
||||||
|
const found = await ndk.fetchEvent( |
||||||
|
{ ids: [currentEvent.id] }, |
||||||
|
undefined, |
||||||
|
relaySet |
||||||
|
).withTimeout(3000); |
||||||
|
return found ? relay : null; |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
// Collect successful results |
||||||
|
foundRelays = results |
||||||
|
.filter((r): r is PromiseFulfilledResult<string | null> => |
||||||
|
r.status === 'fulfilled' && r.value !== null |
||||||
|
) |
||||||
|
.map(r => r.value as string); |
||||||
|
|
||||||
|
} catch (err) { |
||||||
|
console.error('Error searching relays:', err); |
||||||
|
} finally { |
||||||
|
searchingRelays = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function broadcastEvent() { |
||||||
|
if (!event || !$ndkInstance?.activeUser) return; |
||||||
|
broadcasting = true; |
||||||
|
broadcastSuccess = false; |
||||||
|
broadcastError = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const connectedRelays = getConnectedRelays(); |
||||||
|
if (connectedRelays.length === 0) { |
||||||
|
throw new Error('No connected relays available'); |
||||||
|
} |
||||||
|
|
||||||
|
// Create a new event with the same content |
||||||
|
const newEvent = createNDKEvent($ndkInstance, { |
||||||
|
...event.rawEvent(), |
||||||
|
pubkey: $ndkInstance.activeUser.pubkey, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
sig: '' |
||||||
|
}); |
||||||
|
|
||||||
|
// Publish to all relays |
||||||
|
await newEvent.publish(); |
||||||
|
broadcastSuccess = true; |
||||||
|
} catch (err) { |
||||||
|
console.error('Error broadcasting event:', err); |
||||||
|
broadcastError = err instanceof Error ? err.message : 'Failed to broadcast event'; |
||||||
|
} finally { |
||||||
|
broadcasting = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function openRelayModal() { |
||||||
|
showRelayModal = true; |
||||||
|
relaySearchResults = {}; |
||||||
|
searchAllRelaysLive(); |
||||||
|
} |
||||||
|
|
||||||
|
async function searchAllRelaysLive() { |
||||||
|
if (!event) return; |
||||||
|
relaySearchResults = {}; |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(r => r.url); |
||||||
|
allRelays = [ |
||||||
|
...standardRelays, |
||||||
|
...userRelays, |
||||||
|
...fallbackRelays |
||||||
|
].filter((url, idx, arr) => arr.indexOf(url) === idx); |
||||||
|
relaySearchResults = Object.fromEntries(allRelays.map((r: string) => [r, 'pending'])); |
||||||
|
await Promise.all( |
||||||
|
allRelays.map(async (relay: string) => { |
||||||
|
try { |
||||||
|
const relaySet = createRelaySetFromUrls([relay], ndk); |
||||||
|
const found = await ndk.fetchEvent( |
||||||
|
{ ids: [event?.id || ''] }, |
||||||
|
undefined, |
||||||
|
relaySet |
||||||
|
).withTimeout(3000); |
||||||
|
relaySearchResults = { ...relaySearchResults, [relay]: found ? 'found' : 'notfound' }; |
||||||
|
} catch { |
||||||
|
relaySearchResults = { ...relaySearchResults, [relay]: 'notfound' }; |
||||||
|
} |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function closeRelayModal() { |
||||||
|
showRelayModal = false; |
||||||
|
} |
||||||
|
|
||||||
|
async function initializeNDK() { |
||||||
|
const ndk = new NDK({ explicitRelayUrls: [ |
||||||
|
'wss://relay.nostr.band', |
||||||
|
'wss://another.relay', |
||||||
|
'wss://fallback.relay' |
||||||
|
] }); |
||||||
|
await ndk.connect(); |
||||||
|
ndkInstance.set(ndk); |
||||||
|
console.log('Connected relays:', getConnectedRelays()); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2"> |
||||||
|
<Button |
||||||
|
on:click={openRelayModal} |
||||||
|
class="flex items-center" |
||||||
|
> |
||||||
|
{@html searchIcon} |
||||||
|
Where can I find this event? |
||||||
|
</Button> |
||||||
|
|
||||||
|
{#if $ndkInstance?.activeUser} |
||||||
|
<Button |
||||||
|
on:click={broadcastEvent} |
||||||
|
disabled={broadcasting} |
||||||
|
class="flex items-center" |
||||||
|
> |
||||||
|
{@html broadcastIcon} |
||||||
|
{broadcasting ? 'Broadcasting...' : 'Broadcast'} |
||||||
|
</Button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if foundRelays.length > 0} |
||||||
|
<div class="mt-2"> |
||||||
|
<span class="font-semibold">Found on {foundRelays.length} relay(s):</span> |
||||||
|
<div class="flex flex-wrap gap-2 mt-1"> |
||||||
|
{#each foundRelays as relay} |
||||||
|
<RelayDisplay {relay} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if broadcastSuccess} |
||||||
|
<div class="mt-2 p-2 bg-green-100 text-green-700 rounded"> |
||||||
|
Event broadcast successfully to: |
||||||
|
<div class="flex flex-wrap gap-2 mt-1"> |
||||||
|
{#each getConnectedRelays() as relay} |
||||||
|
<RelayDisplay {relay} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if broadcastError} |
||||||
|
<div class="mt-2 p-2 bg-red-100 text-red-700 rounded"> |
||||||
|
{broadcastError} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="mt-2"> |
||||||
|
<span class="font-semibold">Found on:</span> |
||||||
|
<div class="flex flex-wrap gap-2 mt-1"> |
||||||
|
{#each getEventRelays(event) as relay} |
||||||
|
<RelayDisplay {relay} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if showRelayModal} |
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center"> |
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative"> |
||||||
|
<button class="absolute top-2 right-2 text-gray-500 hover:text-gray-800" onclick={closeRelayModal}>×</button> |
||||||
|
<h2 class="text-lg font-semibold mb-4">Relay Search Results</h2> |
||||||
|
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto"> |
||||||
|
{#each Object.entries({ |
||||||
|
'Standard Relays': standardRelays, |
||||||
|
'User Relays': Array.from($ndkInstance?.pool?.relays.values() || []).map(r => r.url), |
||||||
|
'Fallback Relays': fallbackRelays |
||||||
|
}) as [groupName, groupRelays]} |
||||||
|
{#if groupRelays.length > 0} |
||||||
|
<div class="flex flex-col gap-2"> |
||||||
|
<h3 class="font-medium text-gray-700 dark:text-gray-300 sticky top-0 bg-white dark:bg-gray-900 py-2"> |
||||||
|
{groupName} |
||||||
|
</h3> |
||||||
|
{#each groupRelays as relay} |
||||||
|
<RelayDisplay {relay} showStatus={true} status={relaySearchResults[relay] || null} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
<div class="mt-4 flex justify-end"> |
||||||
|
<Button onclick={closeRelayModal}>Close</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
<script lang="ts" context="module"> |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
|
||||||
|
// Get relays from event (prefer event.relay or event.relays, fallback to standardRelays) |
||||||
|
export function getEventRelays(event: NDKEvent): string[] { |
||||||
|
if (event && (event as any).relay) { |
||||||
|
const relay = (event as any).relay; |
||||||
|
return [typeof relay === 'string' ? relay : relay.url]; |
||||||
|
} |
||||||
|
if (event && (event as any).relays && (event as any).relays.length) { |
||||||
|
return (event as any).relays.map((r: any) => typeof r === 'string' ? r : r.url); |
||||||
|
} |
||||||
|
return standardRelays; |
||||||
|
} |
||||||
|
|
||||||
|
export function getConnectedRelays(): string[] { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
return Array.from(ndk?.pool?.relays.values() || []) |
||||||
|
.filter(r => r.status === 1) // Only use connected relays |
||||||
|
.map(r => r.url); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<script lang="ts"> |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { ndkInstance } from "$lib/ndk"; |
||||||
|
import { standardRelays } from "$lib/consts"; |
||||||
|
|
||||||
|
export let relay: string; |
||||||
|
export let showStatus = false; |
||||||
|
export let status: 'pending' | 'found' | 'notfound' | null = null; |
||||||
|
|
||||||
|
// Use a static fallback icon for all relays |
||||||
|
function relayFavicon(relay: string): string { |
||||||
|
return '/favicon.png'; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex items-center gap-2 p-2 rounded border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900"> |
||||||
|
<img |
||||||
|
src={relayFavicon(relay)} |
||||||
|
alt="relay icon" |
||||||
|
class="w-5 h-5 object-contain" |
||||||
|
onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} |
||||||
|
/> |
||||||
|
<span class="font-mono text-xs flex-1">{relay}</span> |
||||||
|
{#if showStatus && status} |
||||||
|
{#if status === 'pending'} |
||||||
|
<svg class="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24"> |
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path> |
||||||
|
</svg> |
||||||
|
{:else if status === 'found'} |
||||||
|
<span class="text-green-600">✓</span> |
||||||
|
{:else} |
||||||
|
<span class="text-red-500">✗</span> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
Loading…
Reference in new issue