Browse Source

interim changes

master
Silberengel 10 months ago
parent
commit
05cdb8eb09
  1. 207
      src/lib/components/EventDetails.svelte
  2. 218
      src/lib/components/EventSearch.svelte
  3. 144
      src/lib/components/PublicationFeed.svelte
  4. 2
      src/lib/components/PublicationHeader.svelte
  5. 3
      src/lib/components/PublicationSection.svelte
  6. 243
      src/lib/components/RelayActions.svelte
  7. 59
      src/lib/components/RelayDisplay.svelte
  8. 2
      src/lib/components/blog/BlogHeader.svelte
  9. 2
      src/lib/components/util/ArticleNav.svelte
  10. 29
      src/lib/components/util/Details.svelte
  11. 7
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  12. 8
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  13. 3
      src/lib/parser.ts
  14. 15
      src/lib/utils.ts
  15. 98
      src/lib/utils/nostrUtils.ts
  16. 4
      src/lib/utils/npubCache.ts
  17. 8
      src/routes/+page.svelte
  18. 433
      src/routes/events/+page.svelte
  19. 3
      src/routes/publication/+page.ts

207
src/lib/components/EventDetails.svelte

@ -0,0 +1,207 @@ @@ -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>

218
src/lib/components/EventSearch.svelte

@ -0,0 +1,218 @@ @@ -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>

144
src/lib/components/PublicationFeed.svelte

@ -2,84 +2,107 @@ @@ -2,84 +2,107 @@
import { indexKind } from '$lib/consts';
import { ndkInstance } from '$lib/ndk';
import { filterValidIndexEvents } from '$lib/utils';
import { fetchEventWithFallback } from '$lib/utils/nostrUtils';
import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte';
import ArticleHeader from './PublicationHeader.svelte';
import { onMount } from 'svelte';
let { relays } = $props<{ relays: string[] }>();
let { relays, fallbackRelays } = $props<{ relays: string[], fallbackRelays: string[] }>();
let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false);
let endOfFeed: boolean = $state(false);
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({});
let loading: boolean = $state(true);
let cutoffTimestamp: number = $derived(
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime()
);
async function getEvents(
before: number | undefined = undefined,
): Promise<void> {
try {
// First try to fetch a single event to verify we can connect to the relays
const testEvent = await fetchEventWithFallback($ndkInstance, {
kinds: [indexKind],
limit: 1,
until: before
});
if (!testEvent) {
console.warn('No events found in initial fetch');
return;
}
// If we found an event, proceed with fetching the full set
let eventSet = await $ndkInstance.fetchEvents(
{
kinds: [indexKind],
limit: 16,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
NDKRelaySet.fromRelayUrls(relays, $ndkInstance)
async function getEvents(before: number | undefined = undefined) {
loading = true;
const ndk = $ndkInstance;
const primaryRelays: string[] = relays;
const fallback: string[] = fallbackRelays.filter((r: string) => !primaryRelays.includes(r));
relayStatuses = Object.fromEntries(primaryRelays.map((r: string) => [r, 'pending']));
let allEvents: NDKEvent[] = [];
// First, try primary relays
await Promise.all(
primaryRelays.map(async (relay: string) => {
try {
const relaySet = NDKRelaySet.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 16,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
const eventArray = Array.from(eventSet);
if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' };
} else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
} catch {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
// If no events found, try fallback relays
if (allEvents.length === 0 && fallback.length > 0) {
relayStatuses = { ...relayStatuses, ...Object.fromEntries(fallback.map((r: string) => [r, 'pending'])) };
await Promise.all(
fallback.map(async (relay: string) => {
try {
const relaySet = NDKRelaySet.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents(
{
kinds: [indexKind],
limit: 16,
until: before,
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet
).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet);
const eventArray = Array.from(eventSet);
if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' };
} else {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
} catch {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
eventSet = filterValidIndexEvents(eventSet);
let eventArray = Array.from(eventSet);
eventArray?.sort((a, b) => b.created_at! - a.created_at!);
if (!eventArray) {
return;
}
endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id;
if (endOfFeed) {
return;
}
const eventMap = new Map([...eventsInView, ...eventArray].map(event => [event.tagAddress(), event]));
const allEvents = Array.from(eventMap.values());
const uniqueIds = new Set(allEvents.map(event => event.tagAddress()));
eventsInView = Array.from(uniqueIds)
.map(id => eventMap.get(id))
.filter(event => event != null) as NDKEvent[];
} catch (err) {
console.error('Error fetching events:', err);
}
// Deduplicate and sort
const eventMap = new Map([...eventsInView, ...allEvents].map(event => [event.tagAddress(), event]));
const uniqueEvents = Array.from(eventMap.values());
uniqueEvents.sort((a, b) => b.created_at! - a.created_at!);
eventsInView = uniqueEvents;
endOfFeed = false; // Could add logic to detect end
loading = false;
}
const getSkeletonIds = (): string[] => {
const skeletonHeight = 124; // The height of the skeleton component in pixels.
// Determine the number of skeletons to display based on the height of the screen.
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2;
const skeletonIds = [];
for (let i = 0; i < skeletonCount; i++) {
skeletonIds.push(`skeleton-${i}`);
@ -99,7 +122,12 @@ @@ -99,7 +122,12 @@
</script>
<div class='leather flex flex-col space-y-4'>
{#if eventsInView.length === 0}
<div class="flex flex-wrap gap-2">
{#each Object.entries(relayStatuses) as [relay, status]}
<span class="text-xs font-mono px-2 py-1 rounded border" class:bg-green-100={status==='found'} class:bg-red-100={status==='notfound'} class:bg-yellow-100={status==='pending'}>{relay}: {status}</span>
{/each}
</div>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' />
{/each}

2
src/lib/components/PublicationHeader.svelte

@ -24,7 +24,7 @@ @@ -24,7 +24,7 @@
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);

3
src/lib/components/PublicationSection.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from '$lib/utils/nostrUtils';
let {
address,
@ -109,7 +110,7 @@ @@ -109,7 +110,7 @@
<TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)}
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}

243
src/lib/components/RelayActions.svelte

@ -0,0 +1,243 @@ @@ -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}>&times;</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}

59
src/lib/components/RelayDisplay.svelte

@ -0,0 +1,59 @@ @@ -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">&#10003;</span>
{:else}
<span class="text-red-500">&#10007;</span>
{/if}
{/if}
</div>

2
src/lib/components/blog/BlogHeader.svelte

@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let hashtags: string = $derived(event.getMatchingTags('t') ?? null);

2
src/lib/components/util/ArticleNav.svelte

@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
}>();
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(indexEvent.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let author: string = $derived(indexgetMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null);
let isLeaf: boolean = $derived(indexEvent.kind === 30041);

29
src/lib/components/util/Details.svelte

@ -3,25 +3,26 @@ @@ -3,25 +3,26 @@
import CardActions from "$components/util/CardActions.svelte";
import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// isModal
// - don't show interactions in modal view
// - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let summary: string = $derived(event.getMatchingTags('summary')[0]?.[1] ?? null);
let type: string = $derived(event.getMatchingTags('type')[0]?.[1] ?? null);
let language: string = $derived(event.getMatchingTags('l')[0]?.[1] ?? null);
let source: string = $derived(event.getMatchingTags('source')[0]?.[1] ?? null);
let publisher: string = $derived(event.getMatchingTags('published_by')[0]?.[1] ?? null);
let identifier: string = $derived(event.getMatchingTags('i')[0]?.[1] ?? null);
let hashtags: [] = $derived(event.getMatchingTags('t') ?? []);
let rootId: string = $derived(event.getMatchingTags('d')[0]?.[1] ?? null);
let title: string = $derived(getMatchingTags(event, 'title')[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1');
let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? null);
let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null);
let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null);
let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null);
let source: string = $derived(getMatchingTags(event, 'source')[0]?.[1] ?? null);
let publisher: string = $derived(getMatchingTags(event, 'published_by')[0]?.[1] ?? null);
let identifier: string = $derived(getMatchingTags(event, 'i')[0]?.[1] ?? null);
let hashtags: string[] = $derived(getMatchingTags(event, 't').map(tag => tag[1]));
let rootId: string = $derived(getMatchingTags(event, 'd')[0]?.[1] ?? null);
let kind = $derived(event.kind);
@ -67,7 +68,7 @@ @@ -67,7 +68,7 @@
{#if hashtags.length}
<div class="tags my-2">
{#each hashtags as tag}
<span class="text-sm">#{tag[1]}</span>
<span class="text-sm">#{tag}</span>
{/each}
</div>
{/if}

7
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
<script lang="ts">
import type { NetworkNode } from "./types";
import { onMount } from "svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// Component props
let { node, selected = false, x, y, onclose } = $props<{
@ -30,7 +31,7 @@ @@ -30,7 +31,7 @@
*/
function getAuthorTag(node: NetworkNode): string {
if (node.event) {
const authorTags = node.event.getMatchingTags("author");
const authorTags = getMatchingTags(node.event, "author");
if (authorTags.length > 0) {
return authorTags[0][1];
}
@ -43,7 +44,7 @@ @@ -43,7 +44,7 @@
*/
function getSummaryTag(node: NetworkNode): string | null {
if (node.event) {
const summaryTags = node.event.getMatchingTags("summary");
const summaryTags = getMatchingTags(node.event, "summary");
if (summaryTags.length > 0) {
return summaryTags[0][1];
}
@ -56,7 +57,7 @@ @@ -56,7 +57,7 @@
*/
function getDTag(node: NetworkNode): string {
if (node.event) {
const dTags = node.event.getMatchingTags("d");
const dTags = getMatchingTags(node.event, "d");
if (dTags.length > 0) {
return dTags[0][1];
}

8
src/lib/navigator/EventNetwork/utils/networkBuilder.ts

@ -9,6 +9,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk"; @@ -9,6 +9,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types";
import { nip19 } from "nostr-tools";
import { standardRelays } from "$lib/consts";
import { getMatchingTags } from '$lib/utils/nostrUtils';
// Configuration
const DEBUG = false; // Set to true to enable debug logging
@ -158,7 +159,7 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -158,7 +159,7 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
// Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>();
events.forEach((event) => {
const aTags = event.getMatchingTags("a");
const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", {
eventId: event.id,
aTagCount: aTags.length
@ -279,8 +280,7 @@ export function processIndexEvent( @@ -279,8 +280,7 @@ export function processIndexEvent(
if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
const sequence = indexEvent
.getMatchingTags("a")
const sequence = getMatchingTags(indexEvent, "a")
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))
@ -321,7 +321,7 @@ export function generateGraph( @@ -321,7 +321,7 @@ export function generateGraph(
rootIndices.forEach((rootIndex) => {
debug("Processing root index", {
rootId: rootIndex.id,
aTags: rootIndex.getMatchingTags("a").length
aTags: getMatchingTags(rootIndex, "a").length
});
processIndexEvent(rootIndex, 0, state, maxLevel);
});

3
src/lib/parser.ts

@ -13,6 +13,7 @@ import type { @@ -13,6 +13,7 @@ import type {
import he from 'he';
import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts.ts';
import { getMatchingTags } from '$lib/utils/nostrUtils';
interface IndexMetadata {
authors?: string[];
@ -636,7 +637,7 @@ export default class Pharos { @@ -636,7 +637,7 @@ export default class Pharos {
content += `${titleLevel} ${title}\n\n`;
// TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62.
let tags = event.getMatchingTags('a');
let tags = getMatchingTags(event, 'a');
if (tags.length === 0) {
tags = event.getMatchingTags('e');
}

15
src/lib/utils.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { getMatchingTags } from "./utils/nostrUtils";
export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({
@ -11,7 +12,7 @@ export function neventEncode(event: NDKEvent, relays: string[]) { @@ -11,7 +12,7 @@ export function neventEncode(event: NDKEvent, relays: string[]) {
}
export function naddrEncode(event: NDKEvent, relays: string[]) {
const dTag = event.getMatchingTags('d')[0]?.[1];
const dTag = getMatchingTags(event, 'd')[0]?.[1];
if (!dTag) {
throw new Error('Event does not have a d tag');
}
@ -24,6 +25,10 @@ export function naddrEncode(event: NDKEvent, relays: string[]) { @@ -24,6 +25,10 @@ export function naddrEncode(event: NDKEvent, relays: string[]) {
});
}
export function nprofileEncode(pubkey: string, relays: string[]) {
return nip19.nprofileEncode({ pubkey, relays });
}
export function formatDate(unixtimestamp: number) {
const months = [
"Jan",
@ -109,11 +114,11 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { @@ -109,11 +114,11 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
// Index events have no content, and they must have `title`, `d`, and `e` tags.
if (
(event.content != null && event.content.length > 0)
|| event.getMatchingTags('title').length === 0
|| event.getMatchingTags('d').length === 0
|| getMatchingTags(event, 'title').length === 0
|| getMatchingTags(event, 'd').length === 0
|| (
event.getMatchingTags('a').length === 0
&& event.getMatchingTags('e').length === 0
getMatchingTags(event, 'a').length === 0
&& getMatchingTags(event, 'e').length === 0
)
) {
events.delete(event);

98
src/lib/utils/nostrUtils.ts

@ -5,6 +5,7 @@ import { npubCache } from './npubCache'; @@ -5,6 +5,7 @@ import { npubCache } from './npubCache';
import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk';
const badgeCheckSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>'
@ -14,6 +15,17 @@ const graduationCapSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" ari @@ -14,6 +15,17 @@ const graduationCapSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" ari
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g;
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g;
export interface NostrProfile {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
}
/**
* HTML escape a string
*/
@ -31,7 +43,7 @@ function escapeHtml(text: string): string { @@ -31,7 +43,7 @@ function escapeHtml(text: string): string {
/**
* Get user metadata for a nostr identifier (npub or nprofile)
*/
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string, nip05?: string}> {
export async function getUserMetadata(identifier: string): Promise<NostrProfile> {
// Remove nostr: prefix if present
const cleanId = identifier.replace(/^nostr:/, '');
@ -65,31 +77,22 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin @@ -65,31 +77,22 @@ export async function getUserMetadata(identifier: string): Promise<{name?: strin
return fallback;
}
const user = ndk.getUser({ pubkey: pubkey });
if (!user) {
npubCache.set(cleanId, fallback);
return fallback;
}
try {
const profile = await user.fetchProfile();
if (!profile) {
npubCache.set(cleanId, fallback);
return fallback;
}
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName,
nip05: profile.nip05
};
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
}
const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] });
const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null;
const metadata: NostrProfile = {
name: profile?.name || fallback.name,
displayName: profile?.displayName,
nip05: profile?.nip05,
picture: profile?.image,
about: profile?.about,
banner: profile?.banner,
website: profile?.website,
lud16: profile?.lud16
};
npubCache.set(cleanId, metadata);
return metadata;
} catch (e) {
npubCache.set(cleanId, fallback);
return fallback;
@ -128,7 +131,19 @@ export async function createProfileLinkWithVerification(identifier: string, disp @@ -128,7 +131,19 @@ export async function createProfileLinkWithVerification(identifier: string, disp
user = ndk.getUser({ pubkey: cleanId });
}
const profile = await user.fetchProfile();
const userRelays = Array.from(ndk.pool?.relays.values() || []).map(r => r.url);
const allRelays = [
...standardRelays,
...userRelays,
...fallbackRelays
].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] },
undefined,
relaySet
);
const profile = profileEvent?.content ? JSON.parse(profileEvent.content) : null;
const nip05 = profile?.nip05;
if (!nip05) {
@ -304,9 +319,9 @@ export async function fetchEventWithFallback( @@ -304,9 +319,9 @@ export async function fetchEventWithFallback(
// Create three relay sets in priority order
const relaySets = [
NDKRelaySet.fromRelayUrls(standardRelays, ndk), // 1. Standard relays
NDKRelaySet.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySet.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort)
NDKRelaySetFromNDK.fromRelayUrls(standardRelays, ndk), // 1. Standard relays
NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in)
NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort)
];
try {
@ -314,7 +329,7 @@ export async function fetchEventWithFallback( @@ -314,7 +329,7 @@ export async function fetchEventWithFallback(
const triedRelaySets: string[] = [];
// Helper function to try fetching from a relay set
async function tryFetchFromRelaySet(relaySet: NDKRelaySet, setName: string): Promise<NDKEvent | null> {
async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise<NDKEvent | null> {
if (relaySet.relays.size === 0) return null;
triedRelaySets.push(setName);
@ -373,4 +388,25 @@ export function toNpub(pubkey: string | undefined): string | null { @@ -373,4 +388,25 @@ export function toNpub(pubkey: string | undefined): string | null {
} catch {
return null;
}
}
export type { NDKEvent, NDKRelaySet, NDKUser };
export { nip19 };
export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) {
return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk);
}
export function createNDKEvent(ndk: NDK, rawEvent: any) {
return new NDKEvent(ndk, rawEvent);
}
/**
* Returns all tags from the event that match the given tag name.
* @param event The NDKEvent object.
* @param tagName The tag name to match (e.g., 'a', 'd', 'title').
* @returns An array of matching tags.
*/
export function getMatchingTags(event: NDKEvent, tagName: string): string[][] {
return event.tags.filter((tag: string[]) => tag[0] === tagName);
}

4
src/lib/utils/npubCache.ts

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
export type NpubMetadata = { name?: string; displayName?: string };
import type { NostrProfile } from './nostrUtils';
export type NpubMetadata = NostrProfile;
class NpubCache {
private cache: Record<string, NpubMetadata> = {};

8
src/routes/+page.svelte

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang='ts'>
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts';
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts';
import { Alert, Button, Dropdown, Radio } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk';
@ -31,7 +31,7 @@ @@ -31,7 +31,7 @@
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} />
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} />
{:else}
<div class='leather w-full flex justify-end'>
<Button>
@ -47,9 +47,9 @@ @@ -47,9 +47,9 @@
</Dropdown>
</div>
{#if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} />
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} />
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} />
{/if}
{/if}
</main>

433
src/routes/events/+page.svelte

@ -1,26 +1,16 @@ @@ -1,26 +1,16 @@
<script lang="ts">
import { Heading, P, Button } from "flowbite-svelte";
import { Input } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { neventEncode, naddrEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
import { Heading, P } from "flowbite-svelte";
import { onMount } from "svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { getMimeTags, getEventType } from "$lib/utils/mime";
import { page } from "$app/stores";
import { nip19 } from 'nostr-tools';
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils";
let searchQuery = $state("");
import type { NDKEvent } from '$lib/utils/nostrUtils';
import EventSearch from '$lib/components/EventSearch.svelte';
import EventDetails from '$lib/components/EventDetails.svelte';
import RelayActions from '$lib/components/RelayActions.svelte';
let loading = false;
let error: string | null = null;
let searchValue = $state<string | null>(null);
let event = $state<NDKEvent | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let showFullContent = $state(false);
let parsedContent = $state('');
let contentPreview = $state('');
let profile = $state<{
name?: string;
display_name?: string;
@ -31,225 +21,24 @@ @@ -31,225 +21,24 @@
lud16?: string;
nip05?: string;
} | null>(null);
let profileTitle = $state<string | null>(null);
async function searchEvent() {
if (!searchQuery.trim()) return;
loading = true;
error = null;
event = null;
// Clean the query
let cleanedQuery = searchQuery.trim().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;
event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
if (!event) {
// Try as pubkey (profile event)
filterOrId = { kinds: [0], authors: [cleanedQuery] };
event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
}
// ... handle not found, etc.
return;
} else if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)) {
try {
const decoded = nip19.decode(cleanedQuery);
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);
error = 'Invalid Nostr identifier.';
loading = false;
return;
}
}
// Optionally: handle NIP-05 here if you want
// else if (cleanedQuery.includes('@')) { ... }
try {
// Use our new utility function to fetch the event
console.log('Searching for event:', filterOrId);
event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000);
if (!event) {
console.warn('[Events] Event not found for filterOrId:', filterOrId);
error = 'Event not found';
} else {
console.log('[Events] Event found:', event);
}
} catch (err) {
console.error('[Events] Error fetching event:', err, 'Query:', searchQuery);
error = 'Error fetching event. Please check the ID and try again.';
} finally {
loading = false;
}
}
function getEventLink(event: NDKEvent): string {
const eventType = getEventType(event.kind || 0);
if (eventType === 'addressable') {
const dTag = event.getMatchingTags('d')[0]?.[1];
if (dTag) {
return './publication?id=${event.id}';
}
}
if (event.kind === 30818) {
return './wiki?id=${event.id}';
}
const nevent = neventEncode(event, standardRelays);
return './events?id=${nevent}';
}
function getEventTypeDisplay(event: NDKEvent): string {
const [mTag, MTag] = getMimeTags(event.kind || 0);
return MTag[1].split('/')[1] || 'Event Kind ${event.kind}';
}
function getEventTitle(event: NDKEvent): string {
return event.getMatchingTags('title')[0]?.[1] || 'Untitled';
}
function getEventSummary(event: NDKEvent): string {
return event.getMatchingTags('summary')[0]?.[1] || '';
}
function getEventAuthor(event: NDKEvent): string {
return event.pubkey;
}
function getEventHashtags(event: NDKEvent): string[] {
return event.tags.filter(tag => tag[0] === 't').map(tag => tag[1]);
}
/**
* Returns HTML for pretty-printed JSON, with naddr addresses and event IDs as links
*/
function jsonWithLinks(obj: any): string {
const NADDR_REGEX = /\b(\d{5}:[a-f0-9]{64}:[a-zA-Z0-9._-]+)\b/g;
const EVENT_ID_REGEX = /\b([0-9a-f]{64})\b/g;
function replacer(_key: string, value: any) {
return value;
}
// Stringify with 2-space indent
let json = JSON.stringify(obj, replacer, 2);
// Replace addresses with links
json = json.replace(NADDR_REGEX, (match) => {
try {
const [kind, pubkey, dtag] = match.split(":");
// Compose a fake event for naddrEncode
const fakeEvent = {
kind: parseInt(kind),
pubkey,
tags: [["d", dtag]],
};
const naddr = naddrEncode(fakeEvent as any, standardRelays);
return "<a href='./events?id=${naddr}' class='text-primary-600 underline' target='_blank'>${match}</a>";
} catch {
return match;
}
});
// Replace event IDs with links
json = json.replace(EVENT_ID_REGEX, (match) => {
function handleEventFound(newEvent: NDKEvent) {
event = newEvent;
if (newEvent.kind === 0) {
try {
const nevent = neventEncode({ id: match, kind: 1 } as NDKEvent, standardRelays);
return "<a href='./events?id=${nevent}' class='text-primary-600 underline' target='_blank'>${match}</a>";
profile = JSON.parse(newEvent.content);
} catch {
return match;
profile = null;
}
});
// Escape < and > for HTML safety, but allow our <a> tags
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
json = json.replace(/&lt;a /g, '<a ').replace(/&lt;\/a&gt;/g, '</a>');
return json;
}
function renderTag(tag: string[]): string {
if (tag[0] === 'a' && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(':');
// Use type assertion as any to satisfy NDKEvent signature for naddrEncode
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) {
// Use type assertion as any to satisfy NDKEvent signature for neventEncode
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>";
profile = null;
}
}
onMount(async () => {
onMount(() => {
const id = $page.url.searchParams.get('id');
if (id) {
searchQuery = id;
await searchEvent();
}
});
$effect(() => {
if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then(html => {
parsedContent = html;
contentPreview = html.slice(0, 250);
});
}
});
$effect(() => {
if (event && event.kind === 0) {
try {
profile = JSON.parse(event.content);
} catch {
profile = null;
}
} else {
profile = null;
}
});
$effect(() => {
if (event && event.kind === 0 && profile && profile.name) {
profileTitle = profile.name;
} else {
profileTitle = null;
searchValue = id;
}
});
</script>
@ -264,190 +53,10 @@ @@ -264,190 +53,10 @@
Use this page to view any event (npub, nprofile, nevent, naddr, note, pubkey, or eventID).
</P>
<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()}
/>
<Button on:click={searchEvent} disabled={loading}>
{loading ? 'Searching...' : 'Search'}
</Button>
</div>
{#if error}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{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}
{#if event && typeof event.getMatchingTags === 'function'}
<div class="flex flex-col space-y-6">
<!-- Event Identifier (plain text, not a link) -->
<div class="text-sm font-mono text-gray-600 dark:text-gray-400 break-all">
{neventEncode(event, standardRelays)}
</div>
<!-- Event Details -->
<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" />
</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" />
</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: Only a-tags and e-tags as hyperlinks -->
{#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>
</div>
{#if !getEventTitle(event) && !event.content}
<div class="p-4 text-gray-500">
No title or content available for this event.
<pre class="text-xs mt-2 bg-gray-100 dark:bg-gray-800 p-2 rounded">
{JSON.stringify(event.rawEvent(), null, 2)}
</pre>
</div>
{/if}
{:else if event}
<div class="text-red-600">Fetched event is not a valid NDKEvent. See console for details.</div>
<EventSearch {loading} {error} {searchValue} {event} onEventFound={handleEventFound} />
{#if event}
<EventDetails {event} {profile} />
<RelayActions {event} />
{/if}
</main>
</div>

3
src/routes/publication/+page.ts

@ -3,6 +3,7 @@ import type { Load } from '@sveltejs/kit'; @@ -3,6 +3,7 @@ import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk';
import { getMatchingTags } from '$lib/utils/nostrUtils';
/**
* Decodes an naddr identifier and returns a filter object
@ -96,7 +97,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom @@ -96,7 +97,7 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
? await fetchEventById(ndk, id)
: await fetchEventByDTag(ndk, dTag!);
const publicationType = indexEvent?.getMatchingTags('type')[0]?.[1];
const publicationType = getMatchingTags(indexEvent, 'type')[0]?.[1];
const fetchPromise = parser.fetch(indexEvent);
return {

Loading…
Cancel
Save